From b857ee4b5d24d5cfa419710690382d87d52e5f38 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 30 Apr 2026 19:20:05 +0200 Subject: [PATCH 1/3] fix: stop nested splits at parent merge joins Symptom: describe could treat a nested split's downstream common tail as the nested merge while the parent branch was supposed to stop at an earlier merge. The emitted MDL moved shared continuation into an else branch, which could leave return-valued microflows with a path lacking a return. Root cause: nested traversal trusted the precomputed split merge even when that join was reachable only after the parent merge. The empty-then swap also ran against that over-wide join. Fix: resolve nested split merges against the parent merge when the computed join is downstream, and only apply the empty-then swap when the resolved nested merge is the active parent merge. Tests: added synthetic regression coverage for downstream-vs-local nested merge resolution and ran make build && make test. --- mdl/executor/cmd_microflows_show_helpers.go | 60 +++++++++++++++++++- mdl/executor/cmd_microflows_traverse_test.go | 40 +++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 296a45c8..18e9b8ca 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -810,10 +810,11 @@ func traverseFlowUntilMerge( } trueFlow, falseFlow := findBranchFlows(flows) + nestedMergeID = resolveNestedMergeID(nestedMergeID, mergeID, trueFlow, falseFlow, flowsByOrigin) // Empty-then swap: negate when true branch is empty but false branch has content. // Skip when both branches go directly to merge (both empty). - if trueFlow != nil && falseFlow != nil && nestedMergeID != "" { + if trueFlow != nil && falseFlow != nil && nestedMergeID != "" && nestedMergeID == mergeID { if trueFlow.DestinationID == nestedMergeID && falseFlow.DestinationID != nestedMergeID { stmt = negateIfCondition(stmt) trueFlow, falseFlow = falseFlow, trueFlow @@ -965,6 +966,63 @@ func continueAfterNestedSplitJoin( traverseFlowUntilMerge(ctx, joinID, parentMergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) } +func resolveNestedMergeID( + nestedMergeID model.ID, + parentMergeID model.ID, + trueFlow *microflows.SequenceFlow, + falseFlow *microflows.SequenceFlow, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) model.ID { + if nestedMergeID != "" && parentMergeID != "" && nestedMergeID != parentMergeID && + canReachNode(parentMergeID, nestedMergeID, flowsByOrigin, make(map[model.ID]bool)) { + for _, flow := range []*microflows.SequenceFlow{trueFlow, falseFlow} { + if flow == nil { + continue + } + if flow.DestinationID == parentMergeID || + canReachNode(flow.DestinationID, parentMergeID, flowsByOrigin, make(map[model.ID]bool)) { + return parentMergeID + } + } + } + if nestedMergeID != "" || parentMergeID == "" { + return nestedMergeID + } + for _, flow := range []*microflows.SequenceFlow{trueFlow, falseFlow} { + if flow == nil { + continue + } + if canReachNode(flow.DestinationID, parentMergeID, flowsByOrigin, make(map[model.ID]bool)) { + return parentMergeID + } + } + return "" +} + +func canReachNode( + currentID model.ID, + targetID model.ID, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + visited map[model.ID]bool, +) bool { + if currentID == "" { + return false + } + if currentID == targetID { + return true + } + if visited[currentID] { + return false + } + visited[currentID] = true + for _, flow := range findNormalFlows(flowsByOrigin[currentID]) { + if canReachNode(flow.DestinationID, targetID, flowsByOrigin, visited) { + return true + } + } + return false +} + // traverseLoopBody traverses activities inside a loop body. // When sourceMap is non-nil, it also records line ranges for each activity node. func traverseLoopBody( diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index defb8a9d..9a7d72fd 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -654,6 +654,46 @@ func TestTraverseFlow_NestedTerminalGuardBranchSuppressesEmptyOuterElse(t *testi } } +func TestResolveNestedMergeID_UsesParentMergeBeforeDownstreamJoin(t *testing.T) { + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("parent_merge"): {mkFlow("parent_merge", "downstream_join")}, + } + trueFlow := mkFlow("nested_split", "parent_merge") + falseFlow := mkFlow("nested_split", "false_branch") + + got := resolveNestedMergeID( + mkID("downstream_join"), + mkID("parent_merge"), + trueFlow, + falseFlow, + flowsByOrigin, + ) + + if got != mkID("parent_merge") { + t.Fatalf("nested split used downstream join %q, want parent merge %q", got, mkID("parent_merge")) + } +} + +func TestResolveNestedMergeID_KeepsIndependentNestedJoin(t *testing.T) { + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("nested_join"): {mkFlow("nested_join", "parent_merge")}, + } + trueFlow := mkFlow("nested_split", "true_branch") + falseFlow := mkFlow("nested_split", "nested_join") + + got := resolveNestedMergeID( + mkID("nested_join"), + mkID("parent_merge"), + trueFlow, + falseFlow, + flowsByOrigin, + ) + + if got != mkID("nested_join") { + t.Fatalf("nested split merge changed to %q, want local nested join %q", got, mkID("nested_join")) + } +} + // ============================================================================= // collectErrorHandlerStatements // ============================================================================= From aa94970b2a907178150c45bb6b29ccf0df776a8a Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sat, 2 May 2026 16:05:32 +0200 Subject: [PATCH 2/3] fix: keep terminal false branches structured Symptom: describe could collapse a real terminal false branch into a guard-style continuation when the branch happened to sit on the split centerline, moving statements outside their original ELSE block. Root cause: guard continuation detection looked only at layout Y position. That made a branch-shaped false flow indistinguishable from the horizontal split-to-tail flow produced for guard-style IF statements. Fix: require the false flow to also use the builder's horizontal right-to-left anchor pair before treating it as a guard continuation. Terminal false branches without that guard-tail flow shape remain inside an explicit ELSE. Tests: make build, make test, make lint-go. --- mdl/executor/cmd_microflows_show_helpers.go | 11 +++-- mdl/executor/cmd_microflows_traverse_test.go | 46 +++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 18e9b8ca..4bc30c6d 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -1442,10 +1442,13 @@ func flowLooksLikeGuardContinuation( return false } // Builder-generated guard continuations sit on the split's horizontal - // centerline. This intentionally relies on mxcli's layout contract so a - // real branch that returns to a merge below the split is not collapsed into - // a guard-style continuation during describe. - return dest.GetPosition().Y == split.GetPosition().Y + // centerline and use the builder's horizontal split→tail flow. This + // intentionally relies on mxcli's layout/anchor contract so a real false + // branch whose activities happen to be aligned with the split is not + // collapsed into a guard-style continuation during describe. + return dest.GetPosition().Y == split.GetPosition().Y && + flow.OriginConnectionIndex == AnchorRight && + flow.DestinationConnectionIndex == AnchorLeft } // findErrorHandlerFlow returns the error handler flow from an activity's outgoing flows. diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index 9a7d72fd..eb101f88 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -563,6 +563,9 @@ func TestTraverseFlow_SequentialIfWithoutElseKeepsContinuationOutsideFirstIf(t * func TestTraverseFlow_GuardBranchWithMultipleActivitiesKeepsContinuationOutsideElse(t *testing.T) { e := newTestExecutor() + falseFlow := mkBranchFlow("split", "tail_log", µflows.ExpressionCase{Expression: "false"}) + falseFlow.OriginConnectionIndex = AnchorRight + falseFlow.DestinationConnectionIndex = AnchorLeft activityMap := map[model.ID]microflows.MicroflowObject{ mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, @@ -585,7 +588,7 @@ func TestTraverseFlow_GuardBranchWithMultipleActivitiesKeepsContinuationOutsideE mkID("start"): {mkFlow("start", "split")}, mkID("split"): { mkBranchFlow("split", "then_log", µflows.ExpressionCase{Expression: "true"}), - mkBranchFlow("split", "tail_log", µflows.ExpressionCase{Expression: "false"}), + falseFlow, }, mkID("then_log"): {mkFlow("then_log", "then_return")}, mkID("tail_log"): {mkFlow("tail_log", "end")}, @@ -607,6 +610,47 @@ func TestTraverseFlow_GuardBranchWithMultipleActivitiesKeepsContinuationOutsideE } } +func TestTraverseFlow_TerminalFalseBranchIsNotGuardContinuation(t *testing.T) { + e := newTestExecutor() + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$IsAllowed"}, + }, + mkID("then_log"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("then_log")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "allowed"}}}, + }, + mkID("then_return"): µflows.EndEvent{BaseMicroflowObject: mkObj("then_return")}, + mkID("false_log"): µflows.ActionActivity{BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("false_log")}, Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'App'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "blocked"}}}}, + mkID("false_return"): µflows.EndEvent{BaseMicroflowObject: mkObj("false_return")}, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "split")}, + mkID("split"): { + mkBranchFlow("split", "then_log", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("split", "false_log", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("then_log"): {mkFlow("then_log", "then_return")}, + mkID("false_log"): {mkFlow("false_log", "false_return")}, + } + + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, nil, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + if !strings.Contains(out, "\nelse\n") { + t.Fatalf("terminal false branch must stay inside an explicit ELSE:\n%s", out) + } + if strings.Index(out, "blocked") < strings.Index(out, "else") { + t.Fatalf("false branch body was emitted outside ELSE:\n%s", out) + } +} + func TestTraverseFlow_NestedTerminalGuardBranchSuppressesEmptyOuterElse(t *testing.T) { e := newTestExecutor() From d6f100c72f88899572e913aece627c3fcfac1397 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sat, 2 May 2026 17:51:52 +0200 Subject: [PATCH 3/3] fix: keep split continuations after structural joins Symptom: describing nested split graphs could duplicate a shared continuation tail inside multiple branch bodies. Re-executing the MDL produced duplicate output-variable declarations and model consistency errors. Root cause: split-join discovery required a join to be reachable from every branch, so partially terminal enum splits lost their shared continuation. It also accepted early joins that nested branches could bypass before reaching the real downstream shared tail. Fix: allow nearest joins reached by multiple continuing branches when no all-branch join exists, continue enum splits through the same split-join helpers as IF splits, and reject join candidates that have downstream non-terminal objects reachable while bypassing the candidate. Tests: go test ./mdl/executor; make build; make lint-go; make test. --- mdl/executor/cmd_microflows_show.go | 128 ++++++++++++++++++- mdl/executor/cmd_microflows_show_helpers.go | 14 +- mdl/executor/cmd_microflows_traverse_test.go | 103 +++++++++++++++ 3 files changed, 232 insertions(+), 13 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index e6539719..f754de2a 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -891,11 +891,13 @@ func findMergeForSplit( } branchDistances := make([]map[model.ID]int, 0, len(flows)) + branchStarts := make([]model.ID, 0, len(flows)) for _, flow := range flows { + branchStarts = append(branchStarts, flow.DestinationID) branchDistances = append(branchDistances, collectReachableDistances(flow.DestinationID, flowsByOrigin)) } - return selectNearestCommonJoin(activityMap, branchDistances) + return selectNearestCommonJoin(activityMap, flowsByOrigin, branchStarts, branchDistances) } // collectReachableDistances collects the shortest normal-flow distance from a @@ -934,6 +936,8 @@ func collectReachableDistances( func selectNearestCommonJoin( activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + branchStarts []model.ID, branchDistances []map[model.ID]int, ) model.ID { if len(branchDistances) < 2 { @@ -942,6 +946,7 @@ func selectNearestCommonJoin( type candidate struct { id model.ID + reachCount int maxDistance int sumDistance int } @@ -969,17 +974,56 @@ func selectNearestCommonJoin( if common { candidates = append(candidates, candidate{ id: nodeID, + reachCount: len(branchDistances), maxDistance: maxDistance, sumDistance: sumDistance, }) } } + if len(candidates) == 0 { + byNode := map[model.ID]candidate{} + for _, distances := range branchDistances { + for nodeID, distance := range distances { + if !isSplitJoinCandidate(activityMap[nodeID]) { + continue + } + c := byNode[nodeID] + c.id = nodeID + c.reachCount++ + if distance > c.maxDistance { + c.maxDistance = distance + } + c.sumDistance += distance + byNode[nodeID] = c + } + } + for _, c := range byNode { + if c.reachCount >= 2 { + candidates = append(candidates, c) + } + } + } + + if len(candidates) == 0 { + return "" + } + + filtered := candidates[:0] + for _, candidate := range candidates { + if splitJoinCandidateDoesNotHaveDownstreamBypass(candidate.id, activityMap, flowsByOrigin, branchStarts) { + filtered = append(filtered, candidate) + } + } + candidates = filtered if len(candidates) == 0 { return "" } sort.Slice(candidates, func(i, j int) bool { + if candidates[i].reachCount != candidates[j].reachCount { + return candidates[i].reachCount > candidates[j].reachCount + } if candidates[i].maxDistance != candidates[j].maxDistance { return candidates[i].maxDistance < candidates[j].maxDistance } @@ -992,6 +1036,88 @@ func selectNearestCommonJoin( return candidates[0].id } +func splitJoinCandidateDoesNotHaveDownstreamBypass( + candidateID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + branchStarts []model.ID, +) bool { + downstream := collectReachableNonTerminalObjects(candidateID, activityMap, flowsByOrigin) + if len(downstream) == 0 { + return true + } + for _, startID := range branchStarts { + if startID == candidateID { + continue + } + if reachesAnyObjectAvoiding(startID, downstream, candidateID, activityMap, flowsByOrigin, map[model.ID]bool{}) { + return false + } + } + return true +} + +func collectReachableNonTerminalObjects( + startID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, +) map[model.ID]bool { + result := map[model.ID]bool{} + var walk func(model.ID) + visited := map[model.ID]bool{startID: true} + walk = func(currentID model.ID) { + if visited[currentID] { + return + } + visited[currentID] = true + if isNonTerminalMicroflowObject(activityMap[currentID]) { + result[currentID] = true + } + for _, flow := range findNormalFlows(flowsByOrigin[currentID]) { + walk(flow.DestinationID) + } + } + for _, flow := range findNormalFlows(flowsByOrigin[startID]) { + walk(flow.DestinationID) + } + return result +} + +func reachesAnyObjectAvoiding( + currentID model.ID, + targets map[model.ID]bool, + avoidID model.ID, + activityMap map[model.ID]microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + visited map[model.ID]bool, +) bool { + if currentID == "" || currentID == avoidID || visited[currentID] { + return false + } + if targets[currentID] { + return true + } + if !isNonTerminalMicroflowObject(activityMap[currentID]) { + return false + } + visited[currentID] = true + for _, flow := range findNormalFlows(flowsByOrigin[currentID]) { + if reachesAnyObjectAvoiding(flow.DestinationID, targets, avoidID, activityMap, flowsByOrigin, visited) { + return true + } + } + return false +} + +func isNonTerminalMicroflowObject(obj microflows.MicroflowObject) bool { + switch obj.(type) { + case nil, *microflows.StartEvent, *microflows.EndEvent, *microflows.ErrorEvent: + return false + default: + return true + } +} + func isSplitJoinCandidate(obj microflows.MicroflowObject) bool { switch obj.(type) { case nil, *microflows.StartEvent, *microflows.EndEvent: diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 4bc30c6d..bb500d78 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -633,12 +633,7 @@ func traverseFlow( emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) emitEnumSplitStatement(ctx, currentID, mergeID, variable, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) - if mergeID != "" { - visited[mergeID] = true - for _, flow := range flowsByOrigin[mergeID] { - traverseFlow(ctx, flow.DestinationID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) - } - } + continueAfterSplitJoin(ctx, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) return } if stmt != "" { @@ -796,12 +791,7 @@ func traverseFlowUntilMerge( emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) emitEnumSplitStatement(ctx, currentID, nestedMergeID, variable, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) recordSourceMap(sourceMap, currentID, startLine, len(*lines)+headerLineCount-1) - if nestedMergeID != "" && nestedMergeID != mergeID { - visited[nestedMergeID] = true - for _, flow := range flowsByOrigin[nestedMergeID] { - traverseFlowUntilMerge(ctx, flow.DestinationID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) - } - } + continueAfterNestedSplitJoin(ctx, nestedMergeID, mergeID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, lines, indent, sourceMap, headerLineCount, annotationsByTarget) return } if stmt != "" { diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index eb101f88..0fda6a65 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -493,6 +493,109 @@ func TestTraverseFlow_CommonActivityJoinKeepsTailOutsideBranches(t *testing.T) { } } +func TestTraverseFlow_EnumSplitPartialJoinKeepsSharedTailOutsideCases(t *testing.T) { + e := newTestExecutor() + + logAction := func(id, message string) *microflows.ActionActivity { + return µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj(id)}, + Action: µflows.LogMessageAction{ + LogLevel: "Info", + LogNodeName: "'Synthetic'", + MessageTemplate: &model.Text{Translations: map[string]string{"en_US": message}}, + }, + } + } + + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("start"): µflows.StartEvent{BaseMicroflowObject: mkObj("start")}, + mkID("kind_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("kind_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Kind"}, + }, + mkID("case_a"): logAction("case_a", "case A"), + mkID("case_b"): logAction("case_b", "case B"), + mkID("case_c"): logAction("case_c", "case C terminal"), + mkID("shared_tail"): logAction("shared_tail", "shared tail"), + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("start"): {mkFlow("start", "kind_split")}, + mkID("kind_split"): { + mkBranchFlow("kind_split", "case_a", microflows.EnumerationCase{Value: "A"}), + mkBranchFlow("kind_split", "case_b", microflows.EnumerationCase{Value: "B"}), + mkBranchFlow("kind_split", "case_c", microflows.EnumerationCase{Value: "C"}), + }, + mkID("case_a"): {mkFlow("case_a", "shared_tail")}, + mkID("case_b"): {mkFlow("case_b", "shared_tail")}, + mkID("case_c"): {mkFlow("case_c", "end")}, + mkID("shared_tail"): {mkFlow("shared_tail", "end")}, + } + + joinID := findMergeForSplit(nil, mkID("kind_split"), flowsByOrigin, activityMap) + if joinID != mkID("shared_tail") { + t.Fatalf("enum split paired with %q, want shared tail %q", joinID, mkID("shared_tail")) + } + + splitMergeMap := map[model.ID]model.ID{mkID("kind_split"): joinID} + var lines []string + visited := make(map[model.ID]bool) + e.traverseFlow(mkID("start"), activityMap, flowsByOrigin, splitMergeMap, visited, nil, nil, &lines, 0, nil, 0, nil) + + out := strings.Join(lines, "\n") + if got := strings.Count(out, "shared tail"); got != 1 { + t.Fatalf("shared tail emitted %d times, want once:\n%s", got, out) + } + endCase := strings.Index(out, "end case;") + sharedTail := strings.Index(out, "shared tail") + if endCase == -1 || sharedTail == -1 || endCase > sharedTail { + t.Fatalf("shared tail must be emitted after enum split closes:\n%s", out) + } +} + +func TestFindMergeForSplit_SkipsJoinBypassedByNestedBranch(t *testing.T) { + activityMap := map[model.ID]microflows.MicroflowObject{ + mkID("outer_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("outer_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$HasExisting"}, + }, + mkID("inner_split"): µflows.ExclusiveSplit{ + BaseMicroflowObject: mkObj("inner_split"), + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$CanUpdate"}, + }, + mkID("early_join"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("early_join")}, + mkID("clear"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("clear")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'Synthetic'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "clear"}}}, + }, + mkID("shared_tail"): µflows.ExclusiveMerge{BaseMicroflowObject: mkObj("shared_tail")}, + mkID("retrieve"): µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("retrieve")}, + Action: µflows.LogMessageAction{LogLevel: "Info", LogNodeName: "'Synthetic'", MessageTemplate: &model.Text{Translations: map[string]string{"en_US": "retrieve"}}}, + }, + mkID("end"): µflows.EndEvent{BaseMicroflowObject: mkObj("end")}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + mkID("outer_split"): { + mkBranchFlow("outer_split", "early_join", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("outer_split", "inner_split", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("inner_split"): { + mkBranchFlow("inner_split", "shared_tail", µflows.ExpressionCase{Expression: "true"}), + mkBranchFlow("inner_split", "early_join", µflows.ExpressionCase{Expression: "false"}), + }, + mkID("early_join"): {mkFlow("early_join", "clear")}, + mkID("clear"): {mkFlow("clear", "shared_tail")}, + mkID("shared_tail"): {mkFlow("shared_tail", "retrieve")}, + mkID("retrieve"): {mkFlow("retrieve", "end")}, + } + + joinID := findMergeForSplit(nil, mkID("outer_split"), flowsByOrigin, activityMap) + if joinID != mkID("shared_tail") { + t.Fatalf("outer split paired with %q, want downstream shared tail %q", joinID, mkID("shared_tail")) + } +} + func TestTraverseFlow_SequentialIfWithoutElseKeepsContinuationOutsideFirstIf(t *testing.T) { e := newTestExecutor()