capdag 0.109.248 → 0.119.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cap-graph-renderer.js +527 -56
- package/capdag.test.js +423 -0
- package/package.json +1 -1
package/cap-graph-renderer.js
CHANGED
|
@@ -271,7 +271,11 @@ function buildStylesheet() {
|
|
|
271
271
|
{
|
|
272
272
|
selector: 'node.show-more',
|
|
273
273
|
style: {
|
|
274
|
-
|
|
274
|
+
// Use the normal node fill; the dashed border is what
|
|
275
|
+
// distinguishes a show-more node from a regular cap.
|
|
276
|
+
// The renderer never reads `--graph-bg` — the graph
|
|
277
|
+
// canvas background is entirely the host's concern.
|
|
278
|
+
'background-color': nodeBg,
|
|
275
279
|
'border-style': 'dashed',
|
|
276
280
|
'border-width': '2px',
|
|
277
281
|
'border-color': nodeBorderHighlighted,
|
|
@@ -739,7 +743,8 @@ function buildStrandGraphData(data) {
|
|
|
739
743
|
});
|
|
740
744
|
}
|
|
741
745
|
let edgeCounter = 0;
|
|
742
|
-
function addEdge(source, target, label, title, fullUrn, edgeClass) {
|
|
746
|
+
function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
|
|
747
|
+
const m = meta || {};
|
|
743
748
|
edges.push({
|
|
744
749
|
id: `strand-edge-${edgeCounter}`,
|
|
745
750
|
source,
|
|
@@ -749,9 +754,32 @@ function buildStrandGraphData(data) {
|
|
|
749
754
|
fullUrn: fullUrn || '',
|
|
750
755
|
edgeClass: edgeClass || '',
|
|
751
756
|
color: edgeHueColor(edgeCounter),
|
|
757
|
+
// `foreachEntry` flags a cap edge as the first cap entering a
|
|
758
|
+
// ForEach body (the "phantom direct edge" in plan builder's
|
|
759
|
+
// terminology). Render-time collapse uses this to relabel the
|
|
760
|
+
// edge with the cap title + (1→n) marker. Defaults to false.
|
|
761
|
+
foreachEntry: m.foreachEntry === true,
|
|
762
|
+
// `singleCapClosedBody` flags a foreach-entry cap edge whose
|
|
763
|
+
// body is closed by a Collect AND contains exactly one cap.
|
|
764
|
+
// Such a cap is both the body entry and the body exit; the
|
|
765
|
+
// render labels the edge with (n→n) to combine iterate+collect.
|
|
766
|
+
singleCapClosedBody: m.singleCapClosedBody === true,
|
|
752
767
|
});
|
|
753
768
|
edgeCounter++;
|
|
754
769
|
}
|
|
770
|
+
// Find the most recently added cap edge whose target is `nodeId`
|
|
771
|
+
// and stamp it with `singleCapClosedBody=true`. Used by the Collect
|
|
772
|
+
// handler when a closed body has exactly one cap (bodyEntry === bodyExit).
|
|
773
|
+
function markSingleCapClosedBody(nodeId) {
|
|
774
|
+
if (!nodeId) return;
|
|
775
|
+
for (let idx = edges.length - 1; idx >= 0; idx--) {
|
|
776
|
+
const e = edges[idx];
|
|
777
|
+
if (e.edgeClass === 'strand-cap-edge' && e.target === nodeId) {
|
|
778
|
+
e.singleCapClosedBody = true;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
755
783
|
|
|
756
784
|
// Entry node — the strand's source media spec.
|
|
757
785
|
const inputSlotId = 'input_slot';
|
|
@@ -768,13 +796,14 @@ function buildStrandGraphData(data) {
|
|
|
768
796
|
let bodyExit = null;
|
|
769
797
|
|
|
770
798
|
// Finalize an outer ForEach body when a nested ForEach starts before
|
|
771
|
-
// the outer's Collect. Mirrors plan_builder.rs:238-289.
|
|
799
|
+
// the outer's Collect. Mirrors plan_builder.rs:238-289. The render
|
|
800
|
+
// collapse will later drop the ForEach node and synthesize the
|
|
801
|
+
// bridging edges, so we only need to emit the plan-builder
|
|
802
|
+
// topology here.
|
|
772
803
|
function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
|
|
773
804
|
const outerForEachInput = outerForEach.index === 0
|
|
774
805
|
? inputSlotId
|
|
775
806
|
: `step_${outerForEach.index - 1}`;
|
|
776
|
-
// Create the ForEach node + direct edge from its input + iteration
|
|
777
|
-
// edge into the body's first cap.
|
|
778
807
|
addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
|
|
779
808
|
addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
780
809
|
addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
|
|
@@ -790,12 +819,27 @@ function buildStrandGraphData(data) {
|
|
|
790
819
|
const toCanonical = canonicalMediaUrn(step.to_spec);
|
|
791
820
|
addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
|
|
792
821
|
|
|
822
|
+
// The first cap inside a ForEach body is the "foreach entry"
|
|
823
|
+
// — its incoming edge crosses the foreach boundary. Strand
|
|
824
|
+
// mode's render collapse relabels that edge with a (1→n)
|
|
825
|
+
// cardinality marker regardless of the cap's own sequence
|
|
826
|
+
// flags, because visually the transition IS the foreach.
|
|
827
|
+
const isForeachEntry = insideForEachBody !== null && bodyEntry === null;
|
|
828
|
+
|
|
793
829
|
let label = body.title;
|
|
794
830
|
const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
|
|
795
831
|
if (cardinality !== '1\u21921') {
|
|
796
832
|
label = `${label} (${cardinality})`;
|
|
797
833
|
}
|
|
798
|
-
addEdge(
|
|
834
|
+
addEdge(
|
|
835
|
+
prevNodeId,
|
|
836
|
+
nodeId,
|
|
837
|
+
label,
|
|
838
|
+
body.title,
|
|
839
|
+
body.cap_urn,
|
|
840
|
+
'strand-cap-edge',
|
|
841
|
+
{ foreachEntry: isForeachEntry }
|
|
842
|
+
);
|
|
799
843
|
|
|
800
844
|
if (insideForEachBody !== null) {
|
|
801
845
|
if (bodyEntry === null) bodyEntry = nodeId;
|
|
@@ -848,13 +892,26 @@ function buildStrandGraphData(data) {
|
|
|
848
892
|
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
849
893
|
addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
850
894
|
|
|
895
|
+
// Single-cap closed body: the single cap serves as both the
|
|
896
|
+
// body entry and the body exit. The render collapse uses
|
|
897
|
+
// this flag to pick the (n→n) combined cardinality marker
|
|
898
|
+
// for the entry edge and emit a plain unlabeled connector
|
|
899
|
+
// for the synthesized exit edge.
|
|
900
|
+
if (bodyEntry !== null && bodyEntry === bodyExit) {
|
|
901
|
+
markSingleCapClosedBody(bodyEntry);
|
|
902
|
+
}
|
|
903
|
+
|
|
851
904
|
insideForEachBody = null;
|
|
852
905
|
bodyEntry = null;
|
|
853
906
|
bodyExit = null;
|
|
854
907
|
prevNodeId = nodeId;
|
|
855
908
|
} else {
|
|
856
909
|
// Standalone Collect — scalar → list-of-one. Mirrors
|
|
857
|
-
// plan_builder.rs:333-355.
|
|
910
|
+
// plan_builder.rs:333-355. There is no enclosing foreach
|
|
911
|
+
// body, so the preceding cap is NOT flagged as a
|
|
912
|
+
// foreach-exit; the render-time collapse will synthesize a
|
|
913
|
+
// plain "collect" marker on the synthesized edge replacing
|
|
914
|
+
// the dropped Collect node.
|
|
858
915
|
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
859
916
|
addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
860
917
|
prevNodeId = nodeId;
|
|
@@ -866,6 +923,10 @@ function buildStrandGraphData(data) {
|
|
|
866
923
|
});
|
|
867
924
|
|
|
868
925
|
// Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
|
|
926
|
+
// An unclosed ForEach with a body is NOT marked as `singleCapClosedBody`
|
|
927
|
+
// because there's no Collect closing it — the body "fans out" but
|
|
928
|
+
// never converges. The body entry still gets (1→n) on its incoming
|
|
929
|
+
// edge via the foreachEntry flag set in the Cap handler.
|
|
869
930
|
if (insideForEachBody !== null) {
|
|
870
931
|
const outer = insideForEachBody;
|
|
871
932
|
const hasBodyEntry = bodyEntry !== null;
|
|
@@ -892,11 +953,264 @@ function buildStrandGraphData(data) {
|
|
|
892
953
|
addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
|
|
893
954
|
addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
|
|
894
955
|
|
|
956
|
+
// Return the raw plan-builder topology. Strand mode collapses
|
|
957
|
+
// ForEach/Collect nodes into edge labels at render time (see
|
|
958
|
+
// `strandCytoscapeElements`); run mode keeps them as explicit
|
|
959
|
+
// nodes because body replicas anchor at the ForEach/Collect
|
|
960
|
+
// junctions.
|
|
895
961
|
return { nodes, edges, sourceSpec, targetSpec };
|
|
896
962
|
}
|
|
897
963
|
|
|
898
|
-
|
|
899
|
-
|
|
964
|
+
// Transform the plan-builder strand topology into the render shape
|
|
965
|
+
// strand mode actually displays. Pure function; does NOT mutate the
|
|
966
|
+
// input. Run mode bypasses this transform and consumes the raw
|
|
967
|
+
// topology directly (see `runCytoscapeElements`).
|
|
968
|
+
//
|
|
969
|
+
// The display rules (per user spec):
|
|
970
|
+
//
|
|
971
|
+
// 1. ForEach and Collect are NOT rendered as nodes. They're
|
|
972
|
+
// execution-layer concepts; the visible semantic is carried on
|
|
973
|
+
// the surrounding cap edges.
|
|
974
|
+
//
|
|
975
|
+
// 2. The first cap edge entering a ForEach body is relabeled to
|
|
976
|
+
// `<cap_title> (1→n)`. The builder flags those edges with
|
|
977
|
+
// `foreachEntry: true`. The (1→n) overrides whatever cardinality
|
|
978
|
+
// the cap's own `input_is_sequence`/`output_is_sequence` would
|
|
979
|
+
// produce, because visually the transition is the foreach.
|
|
980
|
+
//
|
|
981
|
+
// 3. The last cap edge exiting a ForEach body is replaced by a
|
|
982
|
+
// synthesized bridging edge labeled `<cap_title> (n→1)` — the
|
|
983
|
+
// symmetric counterpart of the foreach-entry (1→n) label.
|
|
984
|
+
// For a single-cap body (same cap is entry and exit) the flag
|
|
985
|
+
// `singleCapClosedBody` collapses both markers into a single
|
|
986
|
+
// `(n→n)` label on the entry edge, and the exit side becomes
|
|
987
|
+
// a plain unlabeled connector.
|
|
988
|
+
//
|
|
989
|
+
// 4. Standalone Collect (not wrapping a ForEach) synthesizes a
|
|
990
|
+
// plain edge labeled `"collect"` — the Collect is rendered
|
|
991
|
+
// as a transition, not the preceding cap's own label.
|
|
992
|
+
//
|
|
993
|
+
// 4. If the last cap step's `to_spec` is semantically equivalent
|
|
994
|
+
// to the strand's `target_spec` (via MediaUrn.isEquivalent),
|
|
995
|
+
// the separate `output` target node is dropped and the last
|
|
996
|
+
// cap edge lands on that merged endpoint. Removes the visible
|
|
997
|
+
// duplicate node.
|
|
998
|
+
function collapseStrandShapeTransitions(built) {
|
|
999
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
1000
|
+
const foreachCardinality = cardinalityLabel(false, true); // "1→n"
|
|
1001
|
+
const collectCardinality = cardinalityLabel(true, false); // "n→1"
|
|
1002
|
+
|
|
1003
|
+
// Index for lookups.
|
|
1004
|
+
const nodeById = new Map();
|
|
1005
|
+
for (const n of built.nodes) nodeById.set(n.id, n);
|
|
1006
|
+
|
|
1007
|
+
// Step 1: before dropping Collect nodes, synthesize the
|
|
1008
|
+
// bridging edge that replaces each dropped Collect. For a
|
|
1009
|
+
// Collect node C the plan builder produced:
|
|
1010
|
+
// body_exit → C (strand-collection, label="collect")
|
|
1011
|
+
// C → next (strand-cap-edge, label="")
|
|
1012
|
+
// The collapse drops C and its two touching edges. To preserve
|
|
1013
|
+
// the flow from body_exit to `next`, we synthesize a new edge
|
|
1014
|
+
// body_exit → next. Its label depends on the Collect's
|
|
1015
|
+
// context (determined by inspecting the body_exit cap's own
|
|
1016
|
+
// incoming edge):
|
|
1017
|
+
//
|
|
1018
|
+
// * Standalone Collect (no enclosing foreach; body_exit is a
|
|
1019
|
+
// plain cap with no foreach markers): synthesize a plain
|
|
1020
|
+
// edge labeled "collect" — the Collect itself is the
|
|
1021
|
+
// transition the user sees.
|
|
1022
|
+
//
|
|
1023
|
+
// * Closed-body Collect with a single-cap body (the body_exit
|
|
1024
|
+
// cap edge is flagged `singleCapClosedBody`): synthesize an
|
|
1025
|
+
// unlabeled connector. The single cap's own entry edge is
|
|
1026
|
+
// relabeled with (n→n) in step 2 and carries both the
|
|
1027
|
+
// iterate and collect semantics at once.
|
|
1028
|
+
//
|
|
1029
|
+
// * Closed-body Collect with a multi-cap body (body_exit cap
|
|
1030
|
+
// edge is a plain cap edge inside the body, NOT marked as
|
|
1031
|
+
// foreachEntry or singleCapClosedBody): label the synth
|
|
1032
|
+
// edge with the body-exit cap's title + (n→1) — the
|
|
1033
|
+
// symmetric counterpart of the foreach-entry (1→n) label.
|
|
1034
|
+
const synthesizedExitEdges = [];
|
|
1035
|
+
for (const node of built.nodes) {
|
|
1036
|
+
if (node.nodeClass !== 'strand-collect') continue;
|
|
1037
|
+
const incoming = built.edges.filter(e =>
|
|
1038
|
+
e.target === node.id && e.edgeClass === 'strand-collection');
|
|
1039
|
+
const outgoing = built.edges.filter(e =>
|
|
1040
|
+
e.source === node.id && e.edgeClass === 'strand-cap-edge');
|
|
1041
|
+
for (const inEdge of incoming) {
|
|
1042
|
+
for (const outEdge of outgoing) {
|
|
1043
|
+
const bodyExitNodeId = inEdge.source;
|
|
1044
|
+
const bodyExitCapEdge = built.edges.find(e =>
|
|
1045
|
+
e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
|
|
1046
|
+
// Is the Collect part of a closed foreach body? A Collect
|
|
1047
|
+
// is "closed" when the body_exit cap is reachable from the
|
|
1048
|
+
// same foreach that the Collect closes. We detect this by
|
|
1049
|
+
// checking whether the Collect's target node has any
|
|
1050
|
+
// incoming iteration edge (i.e. there's a strand-foreach
|
|
1051
|
+
// node whose outgoing iteration reaches the body_exit or
|
|
1052
|
+
// one of its upstream cap nodes). Simpler heuristic that
|
|
1053
|
+
// works for every shape we emit: the body_exit has an
|
|
1054
|
+
// ancestor that is a strand-foreach node.
|
|
1055
|
+
let hasEnclosingForeach = false;
|
|
1056
|
+
{
|
|
1057
|
+
const visited = new Set();
|
|
1058
|
+
const stack = [bodyExitNodeId];
|
|
1059
|
+
while (stack.length > 0) {
|
|
1060
|
+
const cur = stack.pop();
|
|
1061
|
+
if (visited.has(cur)) continue;
|
|
1062
|
+
visited.add(cur);
|
|
1063
|
+
const curNode = built.nodes.find(n => n.id === cur);
|
|
1064
|
+
if (curNode && curNode.nodeClass === 'strand-foreach') {
|
|
1065
|
+
hasEnclosingForeach = true;
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
for (const up of built.edges) {
|
|
1069
|
+
if (up.target === cur) stack.push(up.source);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
let synthLabel;
|
|
1074
|
+
let synthTitle;
|
|
1075
|
+
let synthFullUrn;
|
|
1076
|
+
if (!hasEnclosingForeach) {
|
|
1077
|
+
// Standalone Collect.
|
|
1078
|
+
synthLabel = 'collect';
|
|
1079
|
+
synthTitle = 'collect';
|
|
1080
|
+
synthFullUrn = '';
|
|
1081
|
+
} else if (bodyExitCapEdge && bodyExitCapEdge.singleCapClosedBody) {
|
|
1082
|
+
// Single-cap closed body. The cap's own entry edge is
|
|
1083
|
+
// relabeled with (n→n) in step 2; the exit side is just
|
|
1084
|
+
// a connector with no duplicate label.
|
|
1085
|
+
synthLabel = '';
|
|
1086
|
+
synthTitle = '';
|
|
1087
|
+
synthFullUrn = '';
|
|
1088
|
+
} else {
|
|
1089
|
+
// Multi-cap closed body. The body_exit cap's own
|
|
1090
|
+
// incoming edge stays unmodified (carrying just the
|
|
1091
|
+
// cap title, no cardinality markers). The synthesized
|
|
1092
|
+
// exit edge carries the (n→1) collect marker.
|
|
1093
|
+
const title = bodyExitCapEdge ? bodyExitCapEdge.title : '';
|
|
1094
|
+
synthLabel = `${title} (${collectCardinality})`;
|
|
1095
|
+
synthTitle = title;
|
|
1096
|
+
synthFullUrn = bodyExitCapEdge ? bodyExitCapEdge.fullUrn : '';
|
|
1097
|
+
}
|
|
1098
|
+
synthesizedExitEdges.push({
|
|
1099
|
+
id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
|
|
1100
|
+
source: bodyExitNodeId,
|
|
1101
|
+
target: outEdge.target,
|
|
1102
|
+
label: synthLabel,
|
|
1103
|
+
title: synthTitle,
|
|
1104
|
+
fullUrn: synthFullUrn,
|
|
1105
|
+
edgeClass: 'strand-cap-edge',
|
|
1106
|
+
color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
|
|
1107
|
+
foreachEntry: false,
|
|
1108
|
+
singleCapClosedBody: false,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Drop all ForEach/Collect nodes and every edge that touches
|
|
1115
|
+
// them (direct, iteration, collection, and the trailing collect
|
|
1116
|
+
// cap-edge connector). The render never shows those nodes.
|
|
1117
|
+
const dropNodeIds = new Set();
|
|
1118
|
+
for (const node of built.nodes) {
|
|
1119
|
+
if (node.nodeClass === 'strand-foreach' || node.nodeClass === 'strand-collect') {
|
|
1120
|
+
dropNodeIds.add(node.id);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
let nodes = built.nodes.filter(n => !dropNodeIds.has(n.id));
|
|
1124
|
+
let edges = built.edges.filter(e =>
|
|
1125
|
+
!dropNodeIds.has(e.source) &&
|
|
1126
|
+
!dropNodeIds.has(e.target) &&
|
|
1127
|
+
e.edgeClass !== 'strand-iteration' &&
|
|
1128
|
+
e.edgeClass !== 'strand-collection');
|
|
1129
|
+
edges = edges.concat(synthesizedExitEdges);
|
|
1130
|
+
|
|
1131
|
+
// Step 2: relabel flagged foreach-entry cap edges with the
|
|
1132
|
+
// cap title + cardinality marker.
|
|
1133
|
+
//
|
|
1134
|
+
// When the body is a single cap (bodyEntry === bodyExit under a
|
|
1135
|
+
// closed Collect), the cap edge has `singleCapClosedBody: true`
|
|
1136
|
+
// and we use (n→n) — the outer sequence is iterated, the cap
|
|
1137
|
+
// runs per-item, and the result is collected back into a
|
|
1138
|
+
// sequence. Otherwise we use plain (1→n) for the foreach entry.
|
|
1139
|
+
const iterAndCollectCardinality = cardinalityLabel(true, true); // "n→n"
|
|
1140
|
+
edges = edges.map(e => {
|
|
1141
|
+
if (e.edgeClass !== 'strand-cap-edge') return e;
|
|
1142
|
+
if (e.foreachEntry && e.singleCapClosedBody) {
|
|
1143
|
+
return Object.assign({}, e, {
|
|
1144
|
+
label: `${e.title} (${iterAndCollectCardinality})`,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
if (e.foreachEntry) {
|
|
1148
|
+
return Object.assign({}, e, {
|
|
1149
|
+
label: `${e.title} (${foreachCardinality})`,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return e;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Step 3: merge the trailing `step_N → output` edge when step_N
|
|
1156
|
+
// and output represent the same media URN. The strand builder
|
|
1157
|
+
// always emits a separate `output` node with a (possibly empty)
|
|
1158
|
+
// connector edge from the last prev; when the URNs coincide the
|
|
1159
|
+
// output is a visible duplicate.
|
|
1160
|
+
//
|
|
1161
|
+
// Find the `strand-target` node and look for a single incoming
|
|
1162
|
+
// edge from a `strand-cap` (or `strand-source`) node. Compare the
|
|
1163
|
+
// endpoints' `fullUrn` semantically.
|
|
1164
|
+
const targetNode = nodes.find(n => n.nodeClass === 'strand-target');
|
|
1165
|
+
if (targetNode) {
|
|
1166
|
+
const incomingToTarget = edges.filter(e => e.target === targetNode.id);
|
|
1167
|
+
if (incomingToTarget.length === 1) {
|
|
1168
|
+
const trailing = incomingToTarget[0];
|
|
1169
|
+
const upstreamNode = nodes.find(n => n.id === trailing.source);
|
|
1170
|
+
if (upstreamNode && upstreamNode.fullUrn && targetNode.fullUrn) {
|
|
1171
|
+
let equivalent = false;
|
|
1172
|
+
try {
|
|
1173
|
+
const a = MediaUrn.fromString(upstreamNode.fullUrn);
|
|
1174
|
+
const b = MediaUrn.fromString(targetNode.fullUrn);
|
|
1175
|
+
equivalent = a.isEquivalent(b);
|
|
1176
|
+
} catch (_) {
|
|
1177
|
+
equivalent = false;
|
|
1178
|
+
}
|
|
1179
|
+
// Merge only if the trailing edge is the unadorned connector
|
|
1180
|
+
// (empty label). A labeled last-cap edge carries meaningful
|
|
1181
|
+
// info and must not be collapsed away.
|
|
1182
|
+
if (equivalent && (!trailing.label || trailing.label.length === 0)) {
|
|
1183
|
+
// Drop the trailing connector edge and the target node.
|
|
1184
|
+
// The upstream node effectively becomes the target visually.
|
|
1185
|
+
// Rename its display label to the target's display to
|
|
1186
|
+
// preserve the user-configured media_display_names entry.
|
|
1187
|
+
edges = edges.filter(e => e.id !== trailing.id);
|
|
1188
|
+
nodes = nodes
|
|
1189
|
+
.filter(n => n.id !== targetNode.id)
|
|
1190
|
+
.map(n => n.id === upstreamNode.id
|
|
1191
|
+
? Object.assign({}, n, { label: targetNode.label, nodeClass: 'strand-target' })
|
|
1192
|
+
: n);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return { nodes, edges };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function strandCytoscapeElements(built, options) {
|
|
1202
|
+
// Strand mode presents ForEach and Collect as edge labels, not as
|
|
1203
|
+
// boxed nodes. Apply the cosmetic collapse right before emitting
|
|
1204
|
+
// cytoscape elements so the underlying plan-builder topology stays
|
|
1205
|
+
// intact for any callers that need it.
|
|
1206
|
+
//
|
|
1207
|
+
// Run mode opts out (`options.collapse === false`) because its body
|
|
1208
|
+
// replicas visually fan out from the ForEach node and merge at the
|
|
1209
|
+
// Collect node — those junctions must remain as explicit graph
|
|
1210
|
+
// nodes for the fan-in/fan-out to be visible.
|
|
1211
|
+
const shouldCollapse = !(options && options.collapse === false);
|
|
1212
|
+
const source = shouldCollapse ? collapseStrandShapeTransitions(built) : built;
|
|
1213
|
+
const nodeElements = source.nodes.map(node => ({
|
|
900
1214
|
group: 'nodes',
|
|
901
1215
|
data: {
|
|
902
1216
|
id: node.id,
|
|
@@ -905,7 +1219,7 @@ function strandCytoscapeElements(built) {
|
|
|
905
1219
|
},
|
|
906
1220
|
classes: node.nodeClass || '',
|
|
907
1221
|
}));
|
|
908
|
-
const edgeElements =
|
|
1222
|
+
const edgeElements = source.edges.map(edge => ({
|
|
909
1223
|
group: 'edges',
|
|
910
1224
|
data: {
|
|
911
1225
|
id: edge.id,
|
|
@@ -938,31 +1252,74 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
|
938
1252
|
return -1;
|
|
939
1253
|
}
|
|
940
1254
|
|
|
941
|
-
// Remove nodes
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
1255
|
+
// Remove body-interior cap nodes (caps strictly AFTER the first
|
|
1256
|
+
// body cap) from a COLLAPSED strand backbone so that per-body
|
|
1257
|
+
// replicas are the only visible path through the foreach body.
|
|
1258
|
+
// The FIRST body cap node is retained — its incoming foreachEntry
|
|
1259
|
+
// edge is the backbone "fallback connector" that keeps the graph
|
|
1260
|
+
// connected when zero bodies succeed.
|
|
1261
|
+
//
|
|
1262
|
+
// Dropping interior body caps also drops their outgoing edges,
|
|
1263
|
+
// including any synthesized collect-exit edge that would have
|
|
1264
|
+
// connected the last body cap to the post-collect target. To
|
|
1265
|
+
// preserve connectivity, the function replaces those with a
|
|
1266
|
+
// plain bridging edge from the first body cap directly to the
|
|
1267
|
+
// post-collect target.
|
|
1268
|
+
function stripRunBackboneBodyInterior(built, steps, foreachStepIdx, collectStepIdx) {
|
|
1269
|
+
if (foreachStepIdx < 0) return built;
|
|
947
1270
|
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const interiorIds = new Set();
|
|
1271
|
+
|
|
1272
|
+
const interiorStepIdxs = [];
|
|
951
1273
|
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
952
1274
|
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
953
|
-
|
|
1275
|
+
interiorStepIdxs.push(i);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (interiorStepIdxs.length <= 1) {
|
|
1279
|
+
// Zero or one body cap — no prototype chain to strip.
|
|
1280
|
+
return built;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const firstBodyStepId = `step_${interiorStepIdxs[0]}`;
|
|
1284
|
+
const lastBodyStepId = `step_${interiorStepIdxs[interiorStepIdxs.length - 1]}`;
|
|
1285
|
+
const dropInteriorIds = new Set();
|
|
1286
|
+
for (let i = 1; i < interiorStepIdxs.length; i++) {
|
|
1287
|
+
dropInteriorIds.add(`step_${interiorStepIdxs[i]}`);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Find the post-body target: the node that the last body cap's
|
|
1291
|
+
// outgoing edge points at in the collapsed backbone. This is
|
|
1292
|
+
// usually `output` or (after the collect-exit synth) the merged
|
|
1293
|
+
// target node.
|
|
1294
|
+
const postBodyEdge = built.edges.find(e => e.source === lastBodyStepId);
|
|
1295
|
+
const postBodyTarget = postBodyEdge ? postBodyEdge.target : null;
|
|
1296
|
+
|
|
1297
|
+
const keptNodes = built.nodes.filter(n => !dropInteriorIds.has(n.id));
|
|
1298
|
+
let keptEdges = built.edges.filter(e =>
|
|
1299
|
+
!dropInteriorIds.has(e.source) && !dropInteriorIds.has(e.target));
|
|
1300
|
+
|
|
1301
|
+
// Bridge firstBody → postBodyTarget directly with a plain
|
|
1302
|
+
// unlabeled connector so the graph stays connected. Replicas
|
|
1303
|
+
// will overlay labeled chains alongside this connector.
|
|
1304
|
+
if (postBodyTarget && postBodyTarget !== firstBodyStepId) {
|
|
1305
|
+
const bridgeExists = keptEdges.some(e =>
|
|
1306
|
+
e.source === firstBodyStepId && e.target === postBodyTarget);
|
|
1307
|
+
if (!bridgeExists) {
|
|
1308
|
+
keptEdges = keptEdges.concat([{
|
|
1309
|
+
id: `run-bridge-${firstBodyStepId}-${postBodyTarget}`,
|
|
1310
|
+
source: firstBodyStepId,
|
|
1311
|
+
target: postBodyTarget,
|
|
1312
|
+
label: '',
|
|
1313
|
+
title: '',
|
|
1314
|
+
fullUrn: '',
|
|
1315
|
+
edgeClass: 'strand-cap-edge',
|
|
1316
|
+
color: postBodyEdge ? postBodyEdge.color : edgeHueColor(0),
|
|
1317
|
+
foreachEntry: false,
|
|
1318
|
+
singleCapClosedBody: false,
|
|
1319
|
+
}]);
|
|
954
1320
|
}
|
|
955
1321
|
}
|
|
956
|
-
if (interiorIds.size === 0) return built;
|
|
957
|
-
|
|
958
|
-
const keptNodes = built.nodes.filter(n => !interiorIds.has(n.id));
|
|
959
|
-
const keptEdges = built.edges.filter(e =>
|
|
960
|
-
!interiorIds.has(e.source) && !interiorIds.has(e.target));
|
|
961
1322
|
|
|
962
|
-
// After stripping, the ForEach node and the Collect node (or the
|
|
963
|
-
// output node if no Collect) may become disconnected — the body
|
|
964
|
-
// replicas will bridge them. That's fine; cytoscape's ELK layout
|
|
965
|
-
// handles disconnected subgraphs.
|
|
966
1323
|
return {
|
|
967
1324
|
nodes: keptNodes,
|
|
968
1325
|
edges: keptEdges,
|
|
@@ -974,20 +1331,19 @@ function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, colle
|
|
|
974
1331
|
function buildRunGraphData(data) {
|
|
975
1332
|
validateRunPayload(data);
|
|
976
1333
|
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
// ForEach/Collect
|
|
1334
|
+
// Build the raw strand topology and then apply the cosmetic
|
|
1335
|
+
// collapse — run mode uses the SAME cleaned-up backbone that
|
|
1336
|
+
// strand mode uses (no ForEach/Collect nodes). Replicas are an
|
|
1337
|
+
// additional overlay on top of that collapsed backbone.
|
|
980
1338
|
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
981
1339
|
media_display_names: data.media_display_names,
|
|
982
1340
|
});
|
|
983
1341
|
const strandBuiltRaw = buildStrandGraphData(strandInput);
|
|
1342
|
+
const strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
|
|
984
1343
|
|
|
985
|
-
// Locate the ForEach/Collect span in the
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
// step index i has id `step_i`; body replicas fan out from that
|
|
989
|
-
// ForEach node and (when a Collect closes the body) merge into the
|
|
990
|
-
// Collect node at `step_j`.
|
|
1344
|
+
// Locate the ForEach/Collect span in the raw steps. Positional IDs
|
|
1345
|
+
// survive the collapse (node IDs are `step_${i}` from the builder),
|
|
1346
|
+
// so we can still identify which collapsed nodes are the body caps.
|
|
991
1347
|
const steps = data.resolved_strand.steps;
|
|
992
1348
|
let foreachStepIdx = -1;
|
|
993
1349
|
let collectStepIdx = -1;
|
|
@@ -998,16 +1354,14 @@ function buildRunGraphData(data) {
|
|
|
998
1354
|
}
|
|
999
1355
|
const hasForeach = foreachStepIdx >= 0;
|
|
1000
1356
|
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
? stripBodyInteriorFromStrandBackbone(strandBuiltRaw, steps, foreachStepIdx, collectStepIdx)
|
|
1010
|
-
: strandBuiltRaw;
|
|
1357
|
+
// Drop interior body-cap nodes (caps 2..N inside the body) from
|
|
1358
|
+
// the collapsed backbone so the prototype chain doesn't duplicate
|
|
1359
|
+
// the per-body replicas visually. The FIRST body cap is retained
|
|
1360
|
+
// as the body-entry node so the backbone's foreachEntry edge
|
|
1361
|
+
// (`<cap_title> (1→n)`) still has a landing point.
|
|
1362
|
+
let strandBuilt = hasForeach
|
|
1363
|
+
? stripRunBackboneBodyInterior(strandBuiltCollapsed, steps, foreachStepIdx, collectStepIdx)
|
|
1364
|
+
: strandBuiltCollapsed;
|
|
1011
1365
|
|
|
1012
1366
|
// Filter and bound the outcomes.
|
|
1013
1367
|
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
@@ -1029,12 +1383,82 @@ function buildRunGraphData(data) {
|
|
|
1029
1383
|
}
|
|
1030
1384
|
}
|
|
1031
1385
|
|
|
1032
|
-
// Body replicas fan out from the
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
1386
|
+
// Body replicas fan out from the node BEFORE the foreach (which,
|
|
1387
|
+
// in the collapsed backbone, is the source of the foreachEntry
|
|
1388
|
+
// edge). For a foreach at step index k, the pre-foreach node is
|
|
1389
|
+
// `step_${k-1}` when k > 0, or `input_slot` when k == 0. This
|
|
1390
|
+
// node survives the collapse as a regular `strand-cap` or
|
|
1391
|
+
// `strand-source` node.
|
|
1392
|
+
//
|
|
1393
|
+
// Replicas merge back at the node AFTER the body. After collapse,
|
|
1394
|
+
// this is the FIRST body cap node (`step_${bodyCapSteps[0]}`)
|
|
1395
|
+
// when the ForEach has a single-cap body, or the post-collect
|
|
1396
|
+
// node for multi-cap bodies. The post-collect node is whatever
|
|
1397
|
+
// the plan builder's Collect step connected to downstream, which
|
|
1398
|
+
// after collapse is either the next cap (`step_${collectStepIdx+1}`)
|
|
1399
|
+
// or the target/output node (possibly merged into the last body
|
|
1400
|
+
// cap if their URNs are equivalent).
|
|
1401
|
+
const anchorNodeId = hasForeach && foreachStepIdx > 0
|
|
1402
|
+
? `step_${foreachStepIdx - 1}`
|
|
1403
|
+
: 'input_slot';
|
|
1404
|
+
|
|
1405
|
+
// Find the merge node by scanning the collapsed backbone for the
|
|
1406
|
+
// node the body reaches after the foreach body. Start from the
|
|
1407
|
+
// first body cap node and follow forward edges until we leave
|
|
1408
|
+
// the body. For a single-body-cap strand where the body cap IS
|
|
1409
|
+
// the merged target, `mergeNodeId` resolves to that node.
|
|
1410
|
+
let mergeNodeId;
|
|
1411
|
+
{
|
|
1412
|
+
// Find body cap step indices in the resolved strand so we can
|
|
1413
|
+
// identify which step IDs are "inside body" (should be skipped
|
|
1414
|
+
// when finding the merge target).
|
|
1415
|
+
const bodyInteriorSet = new Set();
|
|
1416
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
1417
|
+
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
1418
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
1419
|
+
bodyInteriorSet.add(`step_${i}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// Start from the first body cap and walk forward.
|
|
1423
|
+
const firstBodyCapIdx = (() => {
|
|
1424
|
+
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
1425
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') return i;
|
|
1426
|
+
}
|
|
1427
|
+
return -1;
|
|
1428
|
+
})();
|
|
1429
|
+
if (firstBodyCapIdx < 0) {
|
|
1430
|
+
mergeNodeId = 'output';
|
|
1431
|
+
} else {
|
|
1432
|
+
// Walk the collapsed strand's edges starting from the first
|
|
1433
|
+
// body cap node; the merge target is the first node reached
|
|
1434
|
+
// that isn't inside the body.
|
|
1435
|
+
let cursor = `step_${firstBodyCapIdx}`;
|
|
1436
|
+
let guard = 64;
|
|
1437
|
+
const collapsedNodeIds = new Set(strandBuilt.nodes.map(n => n.id));
|
|
1438
|
+
while (guard-- > 0) {
|
|
1439
|
+
// If the cursor is itself outside the body, we've found it.
|
|
1440
|
+
if (!bodyInteriorSet.has(cursor) && collapsedNodeIds.has(cursor)) {
|
|
1441
|
+
mergeNodeId = cursor;
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
// Otherwise follow the cursor's outgoing edge.
|
|
1445
|
+
const out = strandBuilt.edges.find(e => e.source === cursor);
|
|
1446
|
+
if (!out) {
|
|
1447
|
+
// Nothing to follow — the body cap IS the end of the
|
|
1448
|
+
// strand. Use the cursor as the merge point (it's
|
|
1449
|
+
// typically the merged target node).
|
|
1450
|
+
mergeNodeId = cursor;
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
cursor = out.target;
|
|
1454
|
+
}
|
|
1455
|
+
if (mergeNodeId === undefined) {
|
|
1456
|
+
// Guard exhausted — fall back to output. This is impossible
|
|
1457
|
+
// for any well-formed strand but keeps the runtime safe.
|
|
1458
|
+
mergeNodeId = 'output';
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1038
1462
|
|
|
1039
1463
|
const replicaNodes = [];
|
|
1040
1464
|
const replicaEdges = [];
|
|
@@ -1088,7 +1512,12 @@ function buildRunGraphData(data) {
|
|
|
1088
1512
|
id: `${bodyKey}-e-${i}`,
|
|
1089
1513
|
source: prevBodyNodeId,
|
|
1090
1514
|
target: replicaNodeId,
|
|
1091
|
-
label
|
|
1515
|
+
// Replica edges carry no inline label — the cap title is
|
|
1516
|
+
// identical across every visible replica and would just
|
|
1517
|
+
// pile up as unreadable rotated text over the fan-out.
|
|
1518
|
+
// Hover tooltip exposes the title via `title`; the edge
|
|
1519
|
+
// color + the replica node identify the body.
|
|
1520
|
+
label: '',
|
|
1092
1521
|
title: body.title,
|
|
1093
1522
|
fullUrn: body.cap_urn,
|
|
1094
1523
|
color: `var(${colorVar})`,
|
|
@@ -1124,6 +1553,44 @@ function buildRunGraphData(data) {
|
|
|
1124
1553
|
visibleSuccess.forEach((o) => buildBodyReplica(o));
|
|
1125
1554
|
visibleFailure.forEach((o) => buildBodyReplica(o));
|
|
1126
1555
|
|
|
1556
|
+
// Once any replicas are rendered (success or failure), the
|
|
1557
|
+
// backbone foreach-entry edge becomes a stale placeholder — the
|
|
1558
|
+
// replicas ARE the actual execution traces, so the user
|
|
1559
|
+
// shouldn't see a duplicate labeled "1→n" edge alongside them.
|
|
1560
|
+
// Drop the backbone foreach-entry edge whenever we emit
|
|
1561
|
+
// replicas, AND if there were zero successful replicas also
|
|
1562
|
+
// drop the orphaned merge node: nothing reached it, and
|
|
1563
|
+
// showing a disconnected node misleads the user into thinking
|
|
1564
|
+
// the target was reached.
|
|
1565
|
+
if (hasForeach && (visibleSuccess.length > 0 || visibleFailure.length > 0)) {
|
|
1566
|
+
const backboneForeachEntry = strandBuilt.edges.find(e =>
|
|
1567
|
+
e.edgeClass === 'strand-cap-edge' &&
|
|
1568
|
+
e.source === anchorNodeId &&
|
|
1569
|
+
e.foreachEntry === true);
|
|
1570
|
+
let newEdges = strandBuilt.edges;
|
|
1571
|
+
let newNodes = strandBuilt.nodes;
|
|
1572
|
+
if (backboneForeachEntry) {
|
|
1573
|
+
newEdges = newEdges.filter(e => e.id !== backboneForeachEntry.id);
|
|
1574
|
+
}
|
|
1575
|
+
// If no successful replica will merge into `mergeNodeId`, and
|
|
1576
|
+
// the merge node is now only reachable via the (just-dropped)
|
|
1577
|
+
// backbone edge, drop the merge node itself.
|
|
1578
|
+
if (visibleSuccess.length === 0 && mergeNodeId) {
|
|
1579
|
+
const stillIncoming = newEdges.some(e => e.target === mergeNodeId);
|
|
1580
|
+
const replicaIncoming = replicaEdges.some(e => e.data && e.data.target === mergeNodeId);
|
|
1581
|
+
if (!stillIncoming && !replicaIncoming) {
|
|
1582
|
+
newNodes = newNodes.filter(n => n.id !== mergeNodeId);
|
|
1583
|
+
newEdges = newEdges.filter(e => e.source !== mergeNodeId && e.target !== mergeNodeId);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
strandBuilt = {
|
|
1587
|
+
nodes: newNodes,
|
|
1588
|
+
edges: newEdges,
|
|
1589
|
+
sourceSpec: strandBuilt.sourceSpec,
|
|
1590
|
+
targetSpec: strandBuilt.targetSpec,
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1127
1594
|
// Build success and failure "show more" nodes when there are hidden
|
|
1128
1595
|
// outcomes. Anchored at the ForEach node (or input_slot if none).
|
|
1129
1596
|
const showMoreNodes = [];
|
|
@@ -1200,7 +1667,10 @@ function buildRunGraphData(data) {
|
|
|
1200
1667
|
}
|
|
1201
1668
|
|
|
1202
1669
|
function runCytoscapeElements(built) {
|
|
1203
|
-
|
|
1670
|
+
// Do NOT collapse ForEach/Collect nodes in run mode — body replicas
|
|
1671
|
+
// anchor at the ForEach node (fan-out) and merge at the Collect
|
|
1672
|
+
// node (fan-in). Those junctions must stay visible.
|
|
1673
|
+
const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
|
|
1204
1674
|
return strandElements
|
|
1205
1675
|
.concat(built.replicaNodes)
|
|
1206
1676
|
.concat(built.showMoreNodes)
|
|
@@ -2103,6 +2573,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
2103
2573
|
mediaNodeLabel,
|
|
2104
2574
|
buildBrowseGraphData,
|
|
2105
2575
|
buildStrandGraphData,
|
|
2576
|
+
collapseStrandShapeTransitions,
|
|
2106
2577
|
buildRunGraphData,
|
|
2107
2578
|
buildMachineGraphData,
|
|
2108
2579
|
classifyStrandCapSteps,
|
package/capdag.test.js
CHANGED
|
@@ -3755,6 +3755,7 @@ const {
|
|
|
3755
3755
|
canonicalMediaUrn: rendererCanonicalMediaUrn,
|
|
3756
3756
|
mediaNodeLabel: rendererMediaNodeLabel,
|
|
3757
3757
|
buildStrandGraphData: rendererBuildStrandGraphData,
|
|
3758
|
+
collapseStrandShapeTransitions: rendererCollapseStrandShapeTransitions,
|
|
3758
3759
|
buildRunGraphData: rendererBuildRunGraphData,
|
|
3759
3760
|
buildMachineGraphData: rendererBuildMachineGraphData,
|
|
3760
3761
|
classifyStrandCapSteps: rendererClassifyStrandCapSteps,
|
|
@@ -4204,6 +4205,269 @@ function testRenderer_buildStrandGraphData_nestedForEachThrows() {
|
|
|
4204
4205
|
assert(threw, 'nested ForEach without outer body cap must throw');
|
|
4205
4206
|
}
|
|
4206
4207
|
|
|
4208
|
+
function testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker() {
|
|
4209
|
+
// User spec: ForEach/Collect are NOT rendered as nodes. The
|
|
4210
|
+
// transition is labeled with the enclosed cap's title + a
|
|
4211
|
+
// cardinality marker. For a single-cap body the marker is n→n
|
|
4212
|
+
// (iterate + collect combined) because the same cap is both
|
|
4213
|
+
// the body entry and the body exit.
|
|
4214
|
+
//
|
|
4215
|
+
// Strand [ForEach, Cap(extract), Collect], source=pdf;list,
|
|
4216
|
+
// target=txt;list — target is NOT equivalent to cap's to_spec
|
|
4217
|
+
// media:txt so the output node is retained.
|
|
4218
|
+
//
|
|
4219
|
+
// Expected render shape: 3 nodes (input_slot, step_1, output),
|
|
4220
|
+
// with the entry edge labeled "extract (n→n)" and a plain
|
|
4221
|
+
// unlabeled connector to the output.
|
|
4222
|
+
const payload = {
|
|
4223
|
+
source_spec: 'media:pdf;list',
|
|
4224
|
+
target_spec: 'media:txt;list',
|
|
4225
|
+
steps: [
|
|
4226
|
+
makeForEachStep('media:pdf;list'),
|
|
4227
|
+
makeCapStep('cap:in="media:pdf";op=extract;out="media:txt"', 'extract', 'media:pdf', 'media:txt', false, false),
|
|
4228
|
+
makeCollectStep('media:txt'),
|
|
4229
|
+
],
|
|
4230
|
+
};
|
|
4231
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4232
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4233
|
+
|
|
4234
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4235
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4236
|
+
JSON.stringify(['input_slot', 'output', 'step_1']),
|
|
4237
|
+
'collapse removes the ForEach and Collect nodes; the remaining nodes are source + cap + target');
|
|
4238
|
+
|
|
4239
|
+
// Exactly one edge from input_slot → step_1, carrying the cap
|
|
4240
|
+
// title + iterate+collect cardinality marker.
|
|
4241
|
+
const entryEdges = collapsed.edges.filter(e => e.source === 'input_slot' && e.target === 'step_1');
|
|
4242
|
+
assertEqual(entryEdges.length, 1,
|
|
4243
|
+
'phantom duplicate cap edge must be gone — exactly one edge from source to cap');
|
|
4244
|
+
assertEqual(entryEdges[0].label, 'extract (n\u2192n)',
|
|
4245
|
+
'single-cap-body edge is labeled "<cap_title> (n→n)"');
|
|
4246
|
+
|
|
4247
|
+
// The exit side is a plain unlabeled connector — the cap title
|
|
4248
|
+
// is already shown on the entry edge.
|
|
4249
|
+
const exitEdges = collapsed.edges.filter(e => e.source === 'step_1' && e.target === 'output');
|
|
4250
|
+
assertEqual(exitEdges.length, 1,
|
|
4251
|
+
'there is exactly one exit edge step_1 → output');
|
|
4252
|
+
assertEqual(exitEdges[0].label, '',
|
|
4253
|
+
'exit connector for a single-cap body is unlabeled (cap title already shown on entry edge)');
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
function testRenderer_collapseStrand_unclosedForEachBodyCollapses() {
|
|
4257
|
+
// [Cap_a, ForEach, Cap_b] with no Collect, source=media:a,
|
|
4258
|
+
// target=media:c. Cap_b's to_spec is media:c which is
|
|
4259
|
+
// equivalent to target_spec, so the output node is merged
|
|
4260
|
+
// into step_2.
|
|
4261
|
+
//
|
|
4262
|
+
// Raw topology:
|
|
4263
|
+
// input_slot → step_0 (cap_a) — normal, not in any body
|
|
4264
|
+
// step_0 → step_2 (cap_b, foreachEntry=true — phantom under
|
|
4265
|
+
// plan builder, but the foreach body entry
|
|
4266
|
+
// under the render model)
|
|
4267
|
+
// step_0 → step_1 (foreach direct)
|
|
4268
|
+
// step_1 → step_2 (iteration)
|
|
4269
|
+
// step_2 → output (trailing connector, empty label)
|
|
4270
|
+
//
|
|
4271
|
+
// Collapse:
|
|
4272
|
+
// - step_1 (foreach) removed with its iteration edges.
|
|
4273
|
+
// - step_0 → step_2 relabeled "b (1→n)" via foreachEntry.
|
|
4274
|
+
// - step_2 → output merged because upstream.fullUrn equivalent
|
|
4275
|
+
// to target_spec (both media:c); step_2 takes the target
|
|
4276
|
+
// display label.
|
|
4277
|
+
//
|
|
4278
|
+
// Final: 3 nodes (input_slot, step_0, step_2), 2 edges.
|
|
4279
|
+
const payload = {
|
|
4280
|
+
source_spec: 'media:a',
|
|
4281
|
+
target_spec: 'media:c',
|
|
4282
|
+
steps: [
|
|
4283
|
+
makeCapStep('cap:in="media:a";op=a;out="media:b"', 'a', 'media:a', 'media:b', false, false),
|
|
4284
|
+
makeForEachStep('media:b'),
|
|
4285
|
+
makeCapStep('cap:in="media:b";op=b;out="media:c"', 'b', 'media:b', 'media:c', false, false),
|
|
4286
|
+
],
|
|
4287
|
+
};
|
|
4288
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4289
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4290
|
+
|
|
4291
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4292
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4293
|
+
JSON.stringify(['input_slot', 'step_0', 'step_2']),
|
|
4294
|
+
'foreach node removed and output merged into step_2 (same URN as target)');
|
|
4295
|
+
|
|
4296
|
+
// Exactly one edge from step_0 to step_2, labeled with cap_b's
|
|
4297
|
+
// title + (1→n) marker.
|
|
4298
|
+
const step0ToStep2 = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4299
|
+
assertEqual(step0ToStep2.length, 1,
|
|
4300
|
+
'exactly one step_0 → step_2 edge after dropping the foreach iteration');
|
|
4301
|
+
assertEqual(step0ToStep2[0].label, 'b (1\u2192n)',
|
|
4302
|
+
'the foreach-entry edge is labeled "<cap_b_title> (1→n)"');
|
|
4303
|
+
|
|
4304
|
+
// Cap_a's edge is unchanged (not inside a foreach body).
|
|
4305
|
+
const capA = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4306
|
+
assert(capA !== undefined, 'cap_a edge input_slot → step_0 exists');
|
|
4307
|
+
assertEqual(capA.label, 'a', 'cap_a edge carries just its title (no cardinality marker since 1→1)');
|
|
4308
|
+
|
|
4309
|
+
// After merging, step_2 becomes the render target — no separate
|
|
4310
|
+
// output node exists.
|
|
4311
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4312
|
+
assertEqual(outputNode, undefined,
|
|
4313
|
+
'output node was merged into step_2 because their URNs are semantically equivalent');
|
|
4314
|
+
const mergedTarget = collapsed.nodes.find(n => n.id === 'step_2');
|
|
4315
|
+
assertEqual(mergedTarget.nodeClass, 'strand-target',
|
|
4316
|
+
'merged step_2 takes on the strand-target role');
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
function testRenderer_collapseStrand_standaloneCollectCollapses() {
|
|
4320
|
+
// [Cap, Collect] with no enclosing ForEach, source=media:a,
|
|
4321
|
+
// target=media:b;list (NOT equivalent to cap's to_spec media:b,
|
|
4322
|
+
// so the output node is retained after collapse).
|
|
4323
|
+
//
|
|
4324
|
+
// Collapse:
|
|
4325
|
+
// - step_1 (standalone Collect) removed.
|
|
4326
|
+
// - Synthesized bridging edge step_0 → output labeled "collect".
|
|
4327
|
+
// - The cap edge input_slot → step_0 is unchanged because the
|
|
4328
|
+
// cap is not inside any foreach body.
|
|
4329
|
+
//
|
|
4330
|
+
// Final: 3 nodes (input_slot, step_0, output), 2 edges.
|
|
4331
|
+
const payload = {
|
|
4332
|
+
source_spec: 'media:a',
|
|
4333
|
+
target_spec: 'media:b;list',
|
|
4334
|
+
steps: [
|
|
4335
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4336
|
+
makeCollectStep('media:b'),
|
|
4337
|
+
],
|
|
4338
|
+
};
|
|
4339
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4340
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4341
|
+
|
|
4342
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4343
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4344
|
+
JSON.stringify(['input_slot', 'output', 'step_0']),
|
|
4345
|
+
'collect node removed; only cap + source + target remain');
|
|
4346
|
+
|
|
4347
|
+
const capEdge = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4348
|
+
assert(capEdge !== undefined, 'cap edge survives');
|
|
4349
|
+
assertEqual(capEdge.label, 'x',
|
|
4350
|
+
'cap edge carries just its title — no foreach cardinality markers because the cap is not inside a foreach body');
|
|
4351
|
+
|
|
4352
|
+
const collectEdge = collapsed.edges.find(e => e.source === 'step_0' && e.target === 'output');
|
|
4353
|
+
assert(collectEdge !== undefined, 'step_0 → output edge synthesized by collect collapse');
|
|
4354
|
+
assertEqual(collectEdge.label, 'collect',
|
|
4355
|
+
'the synthesized bridging edge for a standalone Collect is labeled "collect"');
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
function testRenderer_collapseStrand_sequenceProducingCapBeforeForeach() {
|
|
4359
|
+
// Regression test mirroring the user's real strand:
|
|
4360
|
+
// [Cap_disbind (output_is_sequence=true), ForEach, Cap_make_decision],
|
|
4361
|
+
// source = media:pdf, target = media:decision (equivalent to
|
|
4362
|
+
// the last cap's to_spec).
|
|
4363
|
+
//
|
|
4364
|
+
// Expected render shape after collapse:
|
|
4365
|
+
// input_slot → step_0 labeled "Disbind (1→n)" — from Disbind's
|
|
4366
|
+
// own output_is_sequence flag, computed at build time.
|
|
4367
|
+
// step_0 → step_2 labeled "Make a Decision (1→n)" — because
|
|
4368
|
+
// make_decision is the first cap inside an unclosed
|
|
4369
|
+
// ForEach body (foreachEntry=true).
|
|
4370
|
+
// No separate output node because step_2's to_spec equals the
|
|
4371
|
+
// strand target.
|
|
4372
|
+
//
|
|
4373
|
+
// If this test fails, the runtime bug would manifest as either
|
|
4374
|
+
// (a) a duplicate target node, (b) a "for each" labeled edge
|
|
4375
|
+
// where the cap title should be, or (c) the phantom direct cap
|
|
4376
|
+
// edge not being relabeled.
|
|
4377
|
+
const payload = {
|
|
4378
|
+
source_spec: 'media:pdf',
|
|
4379
|
+
target_spec: 'media:decision',
|
|
4380
|
+
steps: [
|
|
4381
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4382
|
+
makeForEachStep('media:page'),
|
|
4383
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4384
|
+
],
|
|
4385
|
+
};
|
|
4386
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4387
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4388
|
+
|
|
4389
|
+
const nodeIds = collapsed.nodes.map(n => n.id).sort();
|
|
4390
|
+
assertEqual(JSON.stringify(nodeIds),
|
|
4391
|
+
JSON.stringify(['input_slot', 'step_0', 'step_2']),
|
|
4392
|
+
'foreach node and duplicate output node both removed');
|
|
4393
|
+
|
|
4394
|
+
// Disbind cap edge carries its own (1→n) marker from
|
|
4395
|
+
// output_is_sequence=true, NOT from the foreach flag.
|
|
4396
|
+
const disbind = collapsed.edges.find(e => e.source === 'input_slot' && e.target === 'step_0');
|
|
4397
|
+
assert(disbind !== undefined, 'Disbind edge input_slot → step_0 exists');
|
|
4398
|
+
assertEqual(disbind.label, 'Disbind (1\u2192n)',
|
|
4399
|
+
'Disbind edge reflects its own output_is_sequence=true cardinality');
|
|
4400
|
+
|
|
4401
|
+
// make_decision cap edge is the foreach entry — the plan-builder
|
|
4402
|
+
// phantom direct edge becomes the render-visible cap edge with
|
|
4403
|
+
// (1→n) appended to the cap title.
|
|
4404
|
+
const makeDecision = collapsed.edges.filter(e => e.source === 'step_0' && e.target === 'step_2');
|
|
4405
|
+
assertEqual(makeDecision.length, 1,
|
|
4406
|
+
'exactly one edge from Text Page to Decision (phantom not duplicated)');
|
|
4407
|
+
assertEqual(makeDecision[0].label, 'Make a Decision (1\u2192n)',
|
|
4408
|
+
'the foreach entry edge is labeled "<cap_title> (1→n)", not "for each"');
|
|
4409
|
+
|
|
4410
|
+
// Duplicate target must be gone.
|
|
4411
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4412
|
+
assertEqual(outputNode, undefined,
|
|
4413
|
+
'output node merged into step_2 because they represent the same URN');
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
function testRenderer_collapseStrand_plainCapMergesTrailingOutput() {
|
|
4417
|
+
// A strand with a single plain 1→1 cap whose to_spec equals
|
|
4418
|
+
// target_spec. The plan-builder topology produces:
|
|
4419
|
+
// input_slot → step_0 (cap) → output
|
|
4420
|
+
// The collapse pass merges the trailing output edge because
|
|
4421
|
+
// step_0 and output represent the same URN (media:b).
|
|
4422
|
+
//
|
|
4423
|
+
// Final: 2 nodes (input_slot, step_0), 1 edge.
|
|
4424
|
+
const payload = {
|
|
4425
|
+
source_spec: 'media:a',
|
|
4426
|
+
target_spec: 'media:b',
|
|
4427
|
+
steps: [
|
|
4428
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4429
|
+
],
|
|
4430
|
+
};
|
|
4431
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4432
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4433
|
+
|
|
4434
|
+
assertEqual(collapsed.nodes.length, 2,
|
|
4435
|
+
'duplicate output node merged into step_0 — 2 nodes remain');
|
|
4436
|
+
const outputNode = collapsed.nodes.find(n => n.id === 'output');
|
|
4437
|
+
assertEqual(outputNode, undefined,
|
|
4438
|
+
'output node dropped by merge');
|
|
4439
|
+
const mergedTarget = collapsed.nodes.find(n => n.id === 'step_0');
|
|
4440
|
+
assertEqual(mergedTarget.nodeClass, 'strand-target',
|
|
4441
|
+
'step_0 takes on the strand-target role after the merge');
|
|
4442
|
+
|
|
4443
|
+
assertEqual(collapsed.edges.length, 1, 'single cap edge remains');
|
|
4444
|
+
assertEqual(collapsed.edges[0].source, 'input_slot');
|
|
4445
|
+
assertEqual(collapsed.edges[0].target, 'step_0');
|
|
4446
|
+
assertEqual(collapsed.edges[0].label, 'x', 'cap title preserved as edge label');
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
function testRenderer_collapseStrand_plainCapDistinctTargetNoMerge() {
|
|
4450
|
+
// A strand with a single plain cap whose to_spec is NOT
|
|
4451
|
+
// equivalent to target_spec. The output node must be retained
|
|
4452
|
+
// and the trailing connector edge preserved.
|
|
4453
|
+
const payload = {
|
|
4454
|
+
source_spec: 'media:a',
|
|
4455
|
+
target_spec: 'media:b;list',
|
|
4456
|
+
steps: [
|
|
4457
|
+
makeCapStep('cap:in="media:a";op=x;out="media:b"', 'x', 'media:a', 'media:b', false, false),
|
|
4458
|
+
],
|
|
4459
|
+
};
|
|
4460
|
+
const built = rendererBuildStrandGraphData(payload);
|
|
4461
|
+
const collapsed = rendererCollapseStrandShapeTransitions(built);
|
|
4462
|
+
|
|
4463
|
+
assertEqual(collapsed.nodes.length, 3,
|
|
4464
|
+
'no merge because cap to_spec (media:b) and target (media:b;list) are semantically distinct');
|
|
4465
|
+
assert(collapsed.nodes.find(n => n.id === 'output') !== undefined,
|
|
4466
|
+
'output node retained');
|
|
4467
|
+
assert(collapsed.nodes.find(n => n.id === 'step_0') !== undefined,
|
|
4468
|
+
'step_0 retained');
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4207
4471
|
function testRenderer_validateStrandPayload_missingSourceSpec() {
|
|
4208
4472
|
let threw = false;
|
|
4209
4473
|
try {
|
|
@@ -4372,6 +4636,156 @@ function testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap() {
|
|
|
4372
4636
|
assertEqual(failureNodes, 2, 'trace truncates at cap y via isEquivalent, yielding 2 failure nodes');
|
|
4373
4637
|
}
|
|
4374
4638
|
|
|
4639
|
+
function testRenderer_buildRunGraphData_backboneHasNoForeachNode() {
|
|
4640
|
+
// Regression test for the run-mode rendering fix: the backbone
|
|
4641
|
+
// delivered to cytoscape must NOT contain any strand-foreach or
|
|
4642
|
+
// strand-collect nodes. Run mode inherits the same cosmetic
|
|
4643
|
+
// collapse as strand mode so the foreach/collect execution-layer
|
|
4644
|
+
// concepts don't leak into the view as boxed nodes.
|
|
4645
|
+
//
|
|
4646
|
+
// User scenario: [Disbind (1→n), ForEach, make_decision] where
|
|
4647
|
+
// target_spec equals the last cap's to_spec, so the backbone
|
|
4648
|
+
// collapses to 3 nodes: input_slot, step_0 (Text Page),
|
|
4649
|
+
// step_2 (Decision, merged target). No separate `for each` or
|
|
4650
|
+
// `collect` boxes.
|
|
4651
|
+
const strand = {
|
|
4652
|
+
source_spec: 'media:pdf',
|
|
4653
|
+
target_spec: 'media:decision',
|
|
4654
|
+
steps: [
|
|
4655
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4656
|
+
makeForEachStep('media:page'),
|
|
4657
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4658
|
+
],
|
|
4659
|
+
};
|
|
4660
|
+
const payload = {
|
|
4661
|
+
resolved_strand: strand,
|
|
4662
|
+
body_outcomes: [],
|
|
4663
|
+
visible_success_count: 0,
|
|
4664
|
+
visible_failure_count: 0,
|
|
4665
|
+
total_body_count: 0,
|
|
4666
|
+
};
|
|
4667
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4668
|
+
|
|
4669
|
+
// Backbone must contain NO foreach/collect nodes.
|
|
4670
|
+
const foreachNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-foreach');
|
|
4671
|
+
const collectNodes = built.strandBuilt.nodes.filter(n => n.nodeClass === 'strand-collect');
|
|
4672
|
+
assertEqual(foreachNodes.length, 0, 'run backbone must not contain strand-foreach nodes');
|
|
4673
|
+
assertEqual(collectNodes.length, 0, 'run backbone must not contain strand-collect nodes');
|
|
4674
|
+
|
|
4675
|
+
// The backbone fallback connector is the foreach-entry cap edge
|
|
4676
|
+
// that runs from the pre-foreach node to the body cap. It must
|
|
4677
|
+
// survive collapse so the target stays reachable even with zero
|
|
4678
|
+
// successful bodies.
|
|
4679
|
+
const backboneCapEdges = built.strandBuilt.edges.filter(e => e.edgeClass === 'strand-cap-edge');
|
|
4680
|
+
assert(backboneCapEdges.some(e => e.source === 'step_0' && e.target === 'step_2'),
|
|
4681
|
+
'foreach-entry backbone edge step_0 → step_2 must be present for fallback connectivity');
|
|
4682
|
+
|
|
4683
|
+
// With zero outcomes, no replicas and no show-more nodes.
|
|
4684
|
+
assertEqual(built.replicaNodes.length, 0, 'no replica nodes when body_outcomes is empty');
|
|
4685
|
+
assertEqual(built.showMoreNodes.length, 0, 'no show-more nodes when no hidden outcomes');
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
function testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder() {
|
|
4689
|
+
// When every body fails, the strand target node was never
|
|
4690
|
+
// reached by any execution. The render drops BOTH the backbone
|
|
4691
|
+
// foreach-entry edge AND the orphaned target node so the user
|
|
4692
|
+
// doesn't see a stale "Decision" placeholder alongside their
|
|
4693
|
+
// failed replicas.
|
|
4694
|
+
const strand = {
|
|
4695
|
+
source_spec: 'media:pdf',
|
|
4696
|
+
target_spec: 'media:decision',
|
|
4697
|
+
steps: [
|
|
4698
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4699
|
+
makeForEachStep('media:page'),
|
|
4700
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4701
|
+
],
|
|
4702
|
+
};
|
|
4703
|
+
const failedCapUrn = 'cap:in="media:page";op=decide;out="media:decision"';
|
|
4704
|
+
const payload = {
|
|
4705
|
+
resolved_strand: strand,
|
|
4706
|
+
body_outcomes: [
|
|
4707
|
+
{ body_index: 0, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
|
|
4708
|
+
{ body_index: 1, success: false, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0, failed_cap: failedCapUrn, error: 'boom' },
|
|
4709
|
+
],
|
|
4710
|
+
visible_success_count: 3,
|
|
4711
|
+
visible_failure_count: 3,
|
|
4712
|
+
total_body_count: 2,
|
|
4713
|
+
};
|
|
4714
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4715
|
+
|
|
4716
|
+
// The dropped placeholder: step_2 (the merged strand target
|
|
4717
|
+
// "Decision") is absent from the backbone because all bodies
|
|
4718
|
+
// failed and the replicas didn't reach it.
|
|
4719
|
+
const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
|
|
4720
|
+
assertEqual(hasStep2, false,
|
|
4721
|
+
'strand target placeholder must be dropped when zero successful replicas reach it');
|
|
4722
|
+
|
|
4723
|
+
// The backbone foreach-entry edge is also gone — replicas
|
|
4724
|
+
// replaced it and there's no orphan target to connect.
|
|
4725
|
+
const foreachEntry = built.strandBuilt.edges.find(e =>
|
|
4726
|
+
e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
|
|
4727
|
+
assertEqual(foreachEntry, undefined,
|
|
4728
|
+
'backbone foreach-entry edge must be dropped when replicas exist');
|
|
4729
|
+
|
|
4730
|
+
// Failures are rendered as red replica trails that don't merge.
|
|
4731
|
+
const failureNodes = built.replicaNodes.filter(n => n.classes === 'body-failure');
|
|
4732
|
+
assertEqual(failureNodes.length, 2,
|
|
4733
|
+
'two failed bodies render two replica nodes (truncated at failed_cap)');
|
|
4734
|
+
|
|
4735
|
+
// The pre-foreach cap node (step_0, "Disbind") is still present
|
|
4736
|
+
// along with its incoming edge — only the post-body parts were
|
|
4737
|
+
// dropped.
|
|
4738
|
+
const hasStep0 = built.strandBuilt.nodes.some(n => n.id === 'step_0');
|
|
4739
|
+
assertEqual(hasStep0, true, 'pre-foreach cap node survives');
|
|
4740
|
+
const disbindEdge = built.strandBuilt.edges.find(e =>
|
|
4741
|
+
e.source === 'input_slot' && e.target === 'step_0');
|
|
4742
|
+
assert(disbindEdge !== undefined, 'pre-foreach cap edge (Disbind) survives');
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
function testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful() {
|
|
4746
|
+
// When at least one successful replica merges into the target,
|
|
4747
|
+
// the backbone foreach-entry edge is dropped — the replica's
|
|
4748
|
+
// own chain represents the execution and the backbone would
|
|
4749
|
+
// otherwise duplicate the path.
|
|
4750
|
+
const strand = {
|
|
4751
|
+
source_spec: 'media:pdf',
|
|
4752
|
+
target_spec: 'media:decision',
|
|
4753
|
+
steps: [
|
|
4754
|
+
makeCapStep('cap:in="media:pdf";op=disbind;out="media:page"', 'Disbind', 'media:pdf', 'media:page', false, true),
|
|
4755
|
+
makeForEachStep('media:page'),
|
|
4756
|
+
makeCapStep('cap:in="media:page";op=decide;out="media:decision"', 'Make a Decision', 'media:page', 'media:decision', false, false),
|
|
4757
|
+
],
|
|
4758
|
+
};
|
|
4759
|
+
const payload = {
|
|
4760
|
+
resolved_strand: strand,
|
|
4761
|
+
body_outcomes: [
|
|
4762
|
+
{ body_index: 0, success: true, cap_urns: [], saved_paths: [], total_bytes: 0, duration_ms: 0 },
|
|
4763
|
+
],
|
|
4764
|
+
visible_success_count: 3,
|
|
4765
|
+
visible_failure_count: 3,
|
|
4766
|
+
total_body_count: 1,
|
|
4767
|
+
};
|
|
4768
|
+
const built = rendererBuildRunGraphData(payload);
|
|
4769
|
+
|
|
4770
|
+
// The backbone foreach-entry edge is gone.
|
|
4771
|
+
const foreachEntry = built.strandBuilt.edges.find(e =>
|
|
4772
|
+
e.edgeClass === 'strand-cap-edge' && e.foreachEntry === true);
|
|
4773
|
+
assertEqual(foreachEntry, undefined,
|
|
4774
|
+
'foreach-entry backbone edge dropped when at least one success exists');
|
|
4775
|
+
|
|
4776
|
+
// step_2 (target) stays because the replica merge edge lands on it.
|
|
4777
|
+
const hasStep2 = built.strandBuilt.nodes.some(n => n.id === 'step_2');
|
|
4778
|
+
assertEqual(hasStep2, true,
|
|
4779
|
+
'strand target node survives when successful replicas merge into it');
|
|
4780
|
+
|
|
4781
|
+
// Exactly one successful replica node and one merge edge.
|
|
4782
|
+
const successNodes = built.replicaNodes.filter(n => n.classes === 'body-success');
|
|
4783
|
+
assertEqual(successNodes.length, 1, 'one replica node for one successful body');
|
|
4784
|
+
const mergeEdges = built.replicaEdges.filter(e =>
|
|
4785
|
+
e.data && e.data.target === 'step_2' && e.classes === 'body-success');
|
|
4786
|
+
assertEqual(mergeEdges.length, 1, 'one merge edge from replica to target');
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4375
4789
|
// ---------------- machine builder ----------------
|
|
4376
4790
|
|
|
4377
4791
|
function testRenderer_validateMachinePayload_rejectsUnknownKind() {
|
|
@@ -4800,6 +5214,12 @@ async function runTests() {
|
|
|
4800
5214
|
runTest('RENDERER: buildStrand_standaloneCollect', testRenderer_buildStrandGraphData_standaloneCollect);
|
|
4801
5215
|
runTest('RENDERER: buildStrand_unclosedForEachBody', testRenderer_buildStrandGraphData_unclosedForEachBody);
|
|
4802
5216
|
runTest('RENDERER: buildStrand_nestedForEachThrows', testRenderer_buildStrandGraphData_nestedForEachThrows);
|
|
5217
|
+
runTest('RENDERER: collapseStrand_singleCapBody', testRenderer_collapseStrand_singleCapBodyShowsCapTitleWithIterCollectMarker);
|
|
5218
|
+
runTest('RENDERER: collapseStrand_unclosedForEachBody', testRenderer_collapseStrand_unclosedForEachBodyCollapses);
|
|
5219
|
+
runTest('RENDERER: collapseStrand_standaloneCollect', testRenderer_collapseStrand_standaloneCollectCollapses);
|
|
5220
|
+
runTest('RENDERER: collapseStrand_seqCapBeforeForeach', testRenderer_collapseStrand_sequenceProducingCapBeforeForeach);
|
|
5221
|
+
runTest('RENDERER: collapseStrand_plainCapMergesOutput', testRenderer_collapseStrand_plainCapMergesTrailingOutput);
|
|
5222
|
+
runTest('RENDERER: collapseStrand_plainCapDistinctTarget', testRenderer_collapseStrand_plainCapDistinctTargetNoMerge);
|
|
4803
5223
|
runTest('RENDERER: validateStrand_missingSourceSpec', testRenderer_validateStrandPayload_missingSourceSpec);
|
|
4804
5224
|
|
|
4805
5225
|
console.log('\n--- cap-graph-renderer run builder ---');
|
|
@@ -4807,6 +5227,9 @@ async function runTests() {
|
|
|
4807
5227
|
runTest('RENDERER: buildRun_pagesSuccessesAndFailures', testRenderer_buildRunGraphData_pagesSuccessesAndFailures);
|
|
4808
5228
|
runTest('RENDERER: buildRun_failureWithoutFailedCap', testRenderer_buildRunGraphData_failureWithoutFailedCapRendersFullTrace);
|
|
4809
5229
|
runTest('RENDERER: buildRun_usesIsEquivalentForFailedCap', testRenderer_buildRunGraphData_usesCapUrnIsEquivalentForFailedCap);
|
|
5230
|
+
runTest('RENDERER: buildRun_backboneHasNoForeachNode', testRenderer_buildRunGraphData_backboneHasNoForeachNode);
|
|
5231
|
+
runTest('RENDERER: buildRun_allFailedDropsPlaceholder', testRenderer_buildRunGraphData_allFailedDropsTargetPlaceholder);
|
|
5232
|
+
runTest('RENDERER: buildRun_backboneDroppedWhenSuccessful', testRenderer_buildRunGraphData_backboneDroppedWhenSuccessful);
|
|
4810
5233
|
|
|
4811
5234
|
console.log('\n--- cap-graph-renderer machine builder ---');
|
|
4812
5235
|
runTest('RENDERER: validateMachine_unknownKind', testRenderer_validateMachinePayload_rejectsUnknownKind);
|
package/package.json
CHANGED