diff --git a/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl b/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl new file mode 100644 index 00000000..6eb706b5 --- /dev/null +++ b/mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl @@ -0,0 +1,55 @@ +-- ============================================================================ +-- Bug #320: DESCRIBE emitted default anchor fragments redundantly +-- ============================================================================ +-- +-- Symptom (before fix): +-- `describe microflow` printed `@anchor` lines for every activity, even +-- when every from/to side matched the MDL default for that flow shape: +-- - regular sequence flows default to `from: right, to: left` +-- - true branches of IF default to `from: right, to: left` +-- - false branches of IF default to `from: bottom, to: top` +-- Output looked like +-- log info node 'X' 'a'; +-- @anchor(from: right, to: left) -- noise: this is the default +-- log info node 'X' 'b'; +-- producing verbose, non-author-friendly DESCRIBE output and noisy +-- `mxcli diff-local` runs after a roundtrip. +-- +-- After fix: +-- `emitAnchorAnnotation` and `emitSplitAnchorAnnotation` (in +-- cmd_microflows_show_helpers.go) suppress fragments that match the +-- default and skip the entire annotation when all sides are defaults. +-- Non-default sides (e.g. `to: top` on a return) are still emitted. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/320-describer-suppress-default-anchor-fragments.mdl -p app.mpr +-- +-- mxcli -p app.mpr -c "describe microflow BugTest320.MF_LinearDefaults" +-- The output must contain NO `@anchor` line — every flow uses defaults. +-- +-- mxcli -p app.mpr -c "describe microflow BugTest320.MF_NonDefaultReturn" +-- The output must keep `@anchor(to: top)` on the return statement. +-- ============================================================================ + +create module BugTest320; + +-- Linear flow, all defaults — describe must emit zero @anchor lines. +create microflow BugTest320.MF_LinearDefaults () +begin + log info node 'BugTest320' 'a'; + log info node 'BugTest320' 'b'; + log info node 'BugTest320' 'c'; +end; +/ + +-- One non-default `to: top` on the return — describe must keep that fragment. +create microflow BugTest320.MF_NonDefaultReturn ( + $value: integer +) +returns boolean as $ok +begin + log info node 'BugTest320' 'check'; + @anchor(to: top) + return $value > 0; +end; +/ diff --git a/mdl/executor/cmd_microflows_anchor_if_test.go b/mdl/executor/cmd_microflows_anchor_if_test.go index 4caefbd7..d79fda39 100644 --- a/mdl/executor/cmd_microflows_anchor_if_test.go +++ b/mdl/executor/cmd_microflows_anchor_if_test.go @@ -10,9 +10,12 @@ package executor import ( + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" ) // buildWithAnchors is a test helper that builds the flow graph for a simple @@ -163,6 +166,48 @@ func TestBuilder_AnchorTrueBranchTo_EmptyThenIfWithElse(t *testing.T) { } } +func TestDescribe_FalseBranchFromTop_IfWithoutElseIsDefault(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.IfStmt{ + Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true}, + ThenBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "inside"}}, + }, + Annotations: &ast.ActivityAnnotations{ + FalseBranchAnchor: &ast.FlowAnchors{From: ast.AnchorSideTop, To: ast.AnchorSideUnset}, + }, + }, + &ast.ReturnStmt{}, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing} + oc := fb.buildFlowGraph(body, nil) + + var split microflows.MicroflowObject + for _, obj := range oc.Objects { + if _, ok := obj.(*microflows.ExclusiveSplit); ok { + split = obj + break + } + } + if split == nil { + t.Fatal("expected exclusive split") + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{} + flowsByDest := map[model.ID][]*microflows.SequenceFlow{} + for _, flow := range oc.Flows { + flowsByOrigin[flow.OriginID] = append(flowsByOrigin[flow.OriginID], flow) + flowsByDest[flow.DestinationID] = append(flowsByDest[flow.DestinationID], flow) + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, flowsByDest, &lines, "") + if len(lines) != 0 { + t.Fatalf("expected false branch from-top default anchor to be omitted, got %q", strings.Join(lines, "\n")) + } +} + func TestBuilder_AnchorToTopOnReturnPreservedInsideElse(t *testing.T) { // Minimal case: single-statement ELSE whose only statement is a RETURN // carrying @anchor(to: top). The flow from the split to that return's diff --git a/mdl/executor/cmd_microflows_describe_anchor_test.go b/mdl/executor/cmd_microflows_describe_anchor_test.go index 011365f8..8a3f1e09 100644 --- a/mdl/executor/cmd_microflows_describe_anchor_test.go +++ b/mdl/executor/cmd_microflows_describe_anchor_test.go @@ -50,6 +50,38 @@ func TestEmitAnchorAnnotation_FromAndTo(t *testing.T) { } } +func TestEmitAnchorAnnotation_OmitsDefaultRightToLeft(t *testing.T) { + activity := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "act-default"}, + }, + }, + } + incoming := µflows.SequenceFlow{ + DestinationID: "act-default", + DestinationConnectionIndex: AnchorLeft, + } + outgoing := µflows.SequenceFlow{ + OriginID: "act-default", + OriginConnectionIndex: AnchorRight, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + "act-default": {outgoing}, + } + flowsByDest := map[model.ID][]*microflows.SequenceFlow{ + "act-default": {incoming}, + } + + var lines []string + emitAnchorAnnotation(activity, flowsByOrigin, flowsByDest, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected default anchor line to be omitted, got %v", lines) + } +} + func TestEmitAnchorAnnotation_NoFlowsSkipsEmission(t *testing.T) { activity := µflows.ActionActivity{ BaseActivity: microflows.BaseActivity{ @@ -66,6 +98,53 @@ func TestEmitAnchorAnnotation_NoFlowsSkipsEmission(t *testing.T) { } } +func TestEmitAnchorAnnotation_IgnoresLoopBodyTailOutgoingFlow(t *testing.T) { + activity := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "body-act"}, + }, + }, + } + loop := µflows.LoopedActivity{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: "loop-1"}, + }, + ObjectCollection: µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{activity}, + }, + } + + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + "body-act": { + { + OriginID: "body-act", + DestinationID: "loop-1", + OriginConnectionIndex: AnchorLeft, + }, + }, + } + flowsByDest := map[model.ID][]*microflows.SequenceFlow{ + "body-act": { + { + DestinationID: "body-act", + DestinationConnectionIndex: AnchorLeft, + }, + }, + } + activityMap := map[model.ID]microflows.MicroflowObject{ + "body-act": activity, + "loop-1": loop, + } + + var lines []string + emitAnchorAnnotationWithActivityMap(activity, flowsByOrigin, flowsByDest, activityMap, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected loop body tail flow to be ignored, got %v", lines) + } +} + func TestAnchorRoundtripViaParserBuilder(t *testing.T) { // Build an AST with an @anchor on a statement, run it through the builder, // and verify the resulting SequenceFlow has the right anchors. Then the diff --git a/mdl/executor/cmd_microflows_describe_concurrent_test.go b/mdl/executor/cmd_microflows_describe_concurrent_test.go index 9f2f3662..1bf06a8b 100644 --- a/mdl/executor/cmd_microflows_describe_concurrent_test.go +++ b/mdl/executor/cmd_microflows_describe_concurrent_test.go @@ -26,7 +26,7 @@ func TestFormatMicroflowActivities_Concurrent_NoRace(t *testing.T) { // Build two distinct microflows whose activities are anchored to // different sides. If the two describe calls share state, one will // emit the other's anchor keyword. - mfA := mkRaceMicroflow("mfa-start", "mfa-log", "mfa-end", AnchorRight) + mfA := mkRaceMicroflow("mfa-start", "mfa-log", "mfa-end", AnchorTop) mfB := mkRaceMicroflow("mfb-start", "mfb-log", "mfb-end", AnchorBottom) e := newTestExecutor() @@ -50,8 +50,8 @@ func TestFormatMicroflowActivities_Concurrent_NoRace(t *testing.T) { } wg.Wait() - wantA := "@anchor(from: right, to: left)" - wantB := "@anchor(from: bottom, to: left)" + wantA := "@anchor(from: top)" + wantB := "@anchor(from: bottom)" for i, got := range resultsA { if !strings.Contains(got, wantA) { t.Errorf("worker %d (A) missing %q in output:\n%s", i, wantA, got) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 3286eae3..9f7a24b0 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -113,6 +113,17 @@ func emitAnchorAnnotation( flowsByDest map[model.ID][]*microflows.SequenceFlow, lines *[]string, indentStr string, +) { + emitAnchorAnnotationWithActivityMap(obj, flowsByOrigin, flowsByDest, nil, lines, indentStr) +} + +func emitAnchorAnnotationWithActivityMap( + obj microflows.MicroflowObject, + flowsByOrigin map[model.ID][]*microflows.SequenceFlow, + flowsByDest map[model.ID][]*microflows.SequenceFlow, + activityMap map[model.ID]microflows.MicroflowObject, + lines *[]string, + indentStr string, ) { id := obj.GetID() @@ -131,7 +142,13 @@ func emitAnchorAnnotation( var from, to string if outgoing := flowsByOrigin[id]; len(outgoing) > 0 { - from = anchorSideKeyword(outgoing[0].OriginConnectionIndex) + for _, flow := range outgoing { + if isNonWritableLoopBodyTailFlow(id, flow, activityMap) { + continue + } + from = anchorSideKeyword(flow.OriginConnectionIndex) + break + } } if incoming := flowsByDest[id]; len(incoming) > 0 { to = anchorSideKeyword(incoming[0].DestinationConnectionIndex) @@ -140,16 +157,37 @@ func emitAnchorAnnotation( if from == "" && to == "" { return } + defaultFrom := anchorSideKeyword(AnchorRight) + defaultTo := anchorSideKeyword(AnchorLeft) var parts []string - if from != "" { + if from != "" && from != defaultFrom { parts = append(parts, "from: "+from) } - if to != "" { + if to != "" && to != defaultTo { parts = append(parts, "to: "+to) } + if len(parts) == 0 { + return + } *lines = append(*lines, indentStr+fmt.Sprintf("@anchor(%s)", strings.Join(parts, ", "))) } +func isNonWritableLoopBodyTailFlow(originID model.ID, flow *microflows.SequenceFlow, activityMap map[model.ID]microflows.MicroflowObject) bool { + if flow == nil || activityMap == nil { + return false + } + loop, ok := activityMap[flow.DestinationID].(*microflows.LoopedActivity) + if !ok || loop.ObjectCollection == nil { + return false + } + for _, obj := range loop.ObjectCollection.Objects { + if obj.GetID() == originID { + return true + } + } + return false +} + // emitSplitAnchorAnnotation emits the split form of @anchor — the incoming // `to: X` plus per-branch `true: (...)` / `false: (...)` — whenever any of the // three has a non-default value. The rendering matches the grammar accepted @@ -189,13 +227,18 @@ func emitSplitAnchorAnnotation( } var parts []string - if inTo != "" { + splitDefaultIn := anchorSideKeyword(AnchorLeft) + trueDefaultFroms := []string{anchorSideKeyword(AnchorRight), anchorSideKeyword(AnchorBottom)} + trueDefaultTos := []string{anchorSideKeyword(AnchorLeft)} + falseDefaultFroms := []string{anchorSideKeyword(AnchorBottom), anchorSideKeyword(AnchorRight)} + falseDefaultTos := []string{anchorSideKeyword(AnchorTop), anchorSideKeyword(AnchorLeft)} + if inTo != "" && inTo != splitDefaultIn { parts = append(parts, "to: "+inTo) } - if p := branchAnchorFragment("true", trueFrom, trueTo); p != "" { + if p := branchAnchorFragmentWithDefaultSides("true", trueFrom, trueTo, trueDefaultFroms, trueDefaultTos); p != "" { parts = append(parts, p) } - if p := branchAnchorFragment("false", falseFrom, falseTo); p != "" { + if p := branchAnchorFragmentWithDefaultSides("false", falseFrom, falseTo, falseDefaultFroms, falseDefaultTos); p != "" { parts = append(parts, p) } if len(parts) == 0 { @@ -220,6 +263,66 @@ func branchAnchorFragment(label, from, to string) string { return fmt.Sprintf("%s: (%s)", label, strings.Join(inner, ", ")) } +// branchAnchorFragmentWithDefaultSides returns the `label: (from: X, to: Y)` +// fragment for a branch anchor, suppressing sides that match the layout +// default and removing the whole fragment when both sides reduce to default. +// +// The function applies suppression in two passes: +// +// 1. Primary suppression — if `from` or `to` is one of the documented +// defaults for this branch (e.g. true branch defaults to from=right or +// from=bottom and to=left), zero it out. +// +// 2. Secondary suppression — when ONE side has already been zeroed by pass 1, +// check whether the surviving side is itself a layout-equivalent default +// that Studio Pro auto-routes. The combinations were observed against +// real Studio Pro output: e.g. on a false branch with no FROM, Studio Pro +// routes to bottom or right automatically; on a true branch with no FROM, +// Studio Pro routes to bottom when the target sits below the split. +// Suppressing these prevents the describer from emitting fragments that +// Studio Pro would have layered identically anyway. +// +// The secondary pass is intentionally order-dependent: it relies on `from` and +// `to` being post-primary-suppression. Paired manual anchors like +// `false: (from: left, to: right)` survive both passes because neither side +// was zeroed by pass 1. +func branchAnchorFragmentWithDefaultSides(label, from, to string, defaultFroms, defaultTos []string) string { + if containsString(defaultFroms, from) { + from = "" + } + if containsString(defaultTos, to) { + to = "" + } + // Secondary suppression: see function comment above for the reasoning. + // Inputs to this switch are already post-primary-suppression. + top := anchorSideKeyword(AnchorTop) + bottom := anchorSideKeyword(AnchorBottom) + right := anchorSideKeyword(AnchorRight) + switch label { + case "false": + if to == "" && from == top { + from = "" + } + if from == "" && (to == bottom || to == right) { + to = "" + } + case "true": + if from == "" && to == bottom { + to = "" + } + } + return branchAnchorFragment(label, from, to) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // emitLoopAnchorAnnotation emits the loop form of @anchor for a LoopedActivity. // A LoopedActivity has up to four flows worth describing: // - the incoming flow from the previous activity (normal `to:`) @@ -327,6 +430,7 @@ func emitObjectAnnotations( annotationsByTarget map[model.ID][]string, flowsByOrigin map[model.ID][]*microflows.SequenceFlow, flowsByDest map[model.ID][]*microflows.SequenceFlow, + activityMap map[model.ID]microflows.MicroflowObject, ) { currentID := obj.GetID() @@ -337,7 +441,7 @@ func emitObjectAnnotations( // @anchor — emit whenever attached flows exist, for roundtrip fidelity. // The emitter sorts out the right form (simple / split / loop) based on // the object type. - emitAnchorAnnotation(obj, flowsByOrigin, flowsByDest, lines, indentStr) + emitAnchorAnnotationWithActivityMap(obj, flowsByOrigin, flowsByDest, activityMap, lines, indentStr) } if activity, ok := obj.(*microflows.ActionActivity); ok { @@ -388,7 +492,7 @@ func emitActivityStatement( } // Emit @ annotations before the statement - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) currentID := obj.GetID() flows := flowsByOrigin[currentID] @@ -506,6 +610,10 @@ func traverseFlow( // Handle ExclusiveSplit specially - need to process both branches if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount + if stmt != "" { + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + *lines = append(*lines, indentStr+stmt) + } flows := flowsByOrigin[currentID] mergeID := splitMergeMap[currentID] @@ -524,7 +632,7 @@ func traverseFlow( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -599,7 +707,7 @@ func traverseFlow( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -674,6 +782,10 @@ func traverseFlowUntilMerge( // Handle nested ExclusiveSplit if _, isSplit := obj.(*microflows.ExclusiveSplit); isSplit { startLine := len(*lines) + headerLineCount + if stmt != "" { + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) + *lines = append(*lines, indentStr+stmt) + } flows := flowsByOrigin[currentID] nestedMergeID := splitMergeMap[currentID] @@ -690,7 +802,7 @@ func traverseFlowUntilMerge( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -763,7 +875,7 @@ func traverseFlowUntilMerge( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -843,7 +955,7 @@ func traverseLoopBody( } if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } @@ -923,7 +1035,7 @@ func traverseLoopBody( if loop, isLoop := obj.(*microflows.LoopedActivity); isLoop { startLine := len(*lines) + headerLineCount if stmt != "" { - emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest) + emitObjectAnnotations(obj, lines, indentStr, annotationsByTarget, flowsByOrigin, flowsByDest, activityMap) *lines = append(*lines, indentStr+stmt) } diff --git a/mdl/executor/cmd_microflows_show_helpers_test.go b/mdl/executor/cmd_microflows_show_helpers_test.go index 48731f06..b57efbad 100644 --- a/mdl/executor/cmd_microflows_show_helpers_test.go +++ b/mdl/executor/cmd_microflows_show_helpers_test.go @@ -149,7 +149,7 @@ func TestEmitObjectAnnotations_EscapesMultilineText(t *testing.T) { var lines []string // Pass nil flow maps — @anchor emission is intentionally suppressed here. - emitObjectAnnotations(obj, &lines, "", annotationsByTarget, nil, nil) + emitObjectAnnotations(obj, &lines, "", annotationsByTarget, nil, nil, nil) got := strings.Join(lines, "\n") if !strings.Contains(got, "@caption 'Caption\\nLine'") { diff --git a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go index 8bb51263..5b61fff4 100644 --- a/mdl/executor/cmd_microflows_split_incoming_anchor_test.go +++ b/mdl/executor/cmd_microflows_split_incoming_anchor_test.go @@ -56,16 +56,16 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { trueFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorRight, - DestinationConnectionIndex: AnchorLeft, + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorTop, CaseValue: microflows.EnumerationCase{ Value: "true", }, } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: microflows.EnumerationCase{ Value: "false", }, @@ -84,8 +84,8 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { out := lines[0] for _, want := range []string{ - "true: (from: right, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top, to: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out) @@ -93,6 +93,145 @@ func TestEmitSplitAnchor_EmitsBranchAnchors(t *testing.T) { } } +func TestEmitSplitAnchor_OmitsDefaultBranchAnchors(t *testing.T) { + splitID := model.ID("split-defaults") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + falseFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorTop, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow, falseFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected default branch anchor line to be omitted, got %v", lines) + } +} + +func TestEmitSplitAnchor_OmitsBuilderNoElseBranchAnchors(t *testing.T) { + splitID := model.ID("split-builder-defaults") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + falseFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow, falseFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 0 { + t.Fatalf("expected builder-generated branch anchors to be omitted, got %v", lines) + } +} + +func TestEmitSplitAnchor_OmitsSingleSidedLayoutEquivalentBranchAnchors(t *testing.T) { + tests := []struct { + name string + flow *microflows.SequenceFlow + }{ + { + name: "false from top", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorLeft, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "false to bottom", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorBottom, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "false to right", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorRight, + CaseValue: µflows.ExpressionCase{Expression: "false"}, + }, + }, + { + name: "true to bottom", + flow: µflows.SequenceFlow{ + OriginConnectionIndex: AnchorRight, + DestinationConnectionIndex: AnchorBottom, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + splitID := model.ID("split-" + strings.ReplaceAll(tt.name, " ", "-")) + split := µflows.ExclusiveSplit{} + split.ID = splitID + tt.flow.OriginID = splitID + + var lines []string + emitAnchorAnnotation(split, map[model.ID][]*microflows.SequenceFlow{splitID: {tt.flow}}, nil, &lines, "") + if len(lines) != 0 { + t.Fatalf("expected layout-equivalent anchor to be omitted, got %v", lines) + } + }) + } +} + +func TestEmitSplitAnchor_EmitsNonDefaultDestinationAgainstBuilderDefaults(t *testing.T) { + splitID := model.ID("split-non-default-destination") + split := µflows.ExclusiveSplit{} + split.ID = splitID + + trueFlow := µflows.SequenceFlow{ + OriginID: splitID, + OriginConnectionIndex: AnchorBottom, + DestinationConnectionIndex: AnchorTop, + CaseValue: µflows.ExpressionCase{Expression: "true"}, + } + flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ + splitID: {trueFlow}, + } + + var lines []string + emitAnchorAnnotation(split, flowsByOrigin, nil, &lines, "") + + if len(lines) != 1 { + t.Fatalf("expected non-default destination anchor to be emitted, got %v", lines) + } + if !strings.Contains(lines[0], "true: (to: top)") { + t.Fatalf("expected true branch destination anchor, got %q", lines[0]) + } +} + func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { // Mendix splits often use ExpressionCase (Expression == "true" / "false") // instead of EnumerationCase. The anchor emission must identify the @@ -105,16 +244,16 @@ func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { trueFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorRight, - DestinationConnectionIndex: AnchorLeft, + OriginConnectionIndex: AnchorTop, + DestinationConnectionIndex: AnchorTop, CaseValue: µflows.ExpressionCase{ Expression: "true", }, } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: µflows.ExpressionCase{ Expression: "false", }, @@ -131,8 +270,8 @@ func TestEmitSplitAnchor_SupportsExpressionCase(t *testing.T) { } out := lines[0] for _, want := range []string{ - "true: (from: right, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top, to: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out) @@ -155,8 +294,8 @@ func TestEmitSplitAnchor_SupportsBooleanCase(t *testing.T) { } falseFlow := µflows.SequenceFlow{ OriginID: splitID, - OriginConnectionIndex: AnchorBottom, - DestinationConnectionIndex: AnchorTop, + OriginConnectionIndex: AnchorLeft, + DestinationConnectionIndex: AnchorRight, CaseValue: µflows.BooleanCase{Value: false}, } flowsByOrigin := map[model.ID][]*microflows.SequenceFlow{ @@ -171,8 +310,8 @@ func TestEmitSplitAnchor_SupportsBooleanCase(t *testing.T) { } out := lines[0] for _, want := range []string{ - "true: (from: top, to: left)", - "false: (from: bottom, to: top)", + "true: (from: top)", + "false: (from: left, to: right)", } { if !strings.Contains(out, want) { t.Errorf("output missing %q\nfull: %s", want, out)