From a640309e54d6d29089cc5665f7901aa14e7c97cb Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 11:53:04 +0200 Subject: [PATCH 1/7] fix: handle missing TaskPage key in workflow activity mutation When setting PAGE on a workflow activity where the TaskPage BSON key is absent (not just nil), dSet silently failed because it only updates existing keys. Added replaceActivity helper that appends the key to the activity document and replaces it in the BSON tree by match. Three cases now handled correctly: - TaskPage exists with value: update Page field in place - TaskPage exists with nil value: replace via dSet - TaskPage absent: append key, replace activity in tree --- mdl/backend/mpr/workflow_mutator.go | 66 +++++++++++++++++++++--- mdl/backend/mpr/workflow_mutator_test.go | 15 +++--- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 01c00238..74926e89 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -162,14 +162,21 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, case "PAGE": taskPage := dGetDoc(actDoc, "TaskPage") if taskPage != nil { + // TaskPage exists and has a value — update the Page field in place. dSet(taskPage, "Page", value) + return nil + } + pageRef := bson.D{ + {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, + {Key: "$Type", Value: "Workflows$PageReference"}, + {Key: "Page", Value: value}, + } + if dSet(actDoc, "TaskPage", pageRef) { + // TaskPage key exists (nil value) — replaced in place via dSet. } else { - pageRef := bson.D{ - {Key: "$ID", Value: bsonutil.NewIDBsonBinary()}, - {Key: "$Type", Value: "Workflows$PageReference"}, - {Key: "Page", Value: value}, - } - dSet(actDoc, "TaskPage", pageRef) + // TaskPage key absent — append to activity and replace in BSON tree. + actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef}) + m.replaceActivity(actDoc) } return nil @@ -555,6 +562,53 @@ func (m *mprWorkflowMutator) Save() error { // Internal helpers — activity search // --------------------------------------------------------------------------- +// replaceActivity replaces an activity document in the workflow's BSON tree +// by matching on $ID. This is needed when appending new keys to an activity +// document, because the slice header returned by findActivityByCaption cannot +// propagate appends back to the parent bson.A. +func (m *mprWorkflowMutator) replaceActivity(updated bson.D) { + actID := extractBinaryIDFromDoc(dGet(updated, "$ID")) + if actID == "" { + return + } + flow := dGetDoc(m.rawData, "Flow") + if flow == nil { + return + } + replaceActivityRecursive(flow, actID, updated) +} + +func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool { + activitiesVal := dGet(flow, "Activities") + arr := toBsonA(activitiesVal) + if len(arr) == 0 { + return false + } + // Skip the int32 type marker at index 0 if present. + start := 0 + if _, ok := arr[0].(int32); ok { + start = 1 + } else if _, ok := arr[0].(int); ok { + start = 1 + } + for i := start; i < len(arr); i++ { + actDoc, ok := arr[i].(bson.D) + if !ok { + continue + } + if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID { + arr[i] = updated + return true + } + for _, nestedFlow := range getNestedFlows(actDoc) { + if replaceActivityRecursive(nestedFlow, actID, updated) { + return true + } + } + } + return false +} + // findActivityByCaption searches the workflow for an activity matching caption. func (m *mprWorkflowMutator) findActivityByCaption(caption string, atPosition int) (bson.D, error) { flow := dGetDoc(m.rawData, "Flow") diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index d821614f..f6378558 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1136,8 +1136,7 @@ func TestWorkflowMutator_InsertBoundaryEvent_NoDelay(t *testing.T) { // --------------------------------------------------------------------------- func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { - // Note: When TaskPage key doesn't pre-exist in BSON, dSet silently fails. - // The key must be present (even as nil) for PAGE to work on a new activity. + // TaskPage key present with nil value — should be replaced with a new PageReference. act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "TaskPage", Value: nil}) m := newMutator(makeWorkflowDoc(act)) @@ -1157,21 +1156,23 @@ func TestWorkflowMutator_SetActivityProperty_Page_New(t *testing.T) { } func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { - // BUG: dSet silently fails when TaskPage key is absent — pageRef is lost. + // Regression test: dSet silently failed when TaskPage key was absent. + // Fixed by appending the key to the activity and replacing it in the BSON tree. act := makeWfActivity("Workflows$UserTask", "Review", "task1") // No TaskPage field at all m := newMutator(makeWorkflowDoc(act)) - // No error returned, but the set is silently lost if err := m.SetActivityProperty("Review", 0, "PAGE", "MyModule.TaskPage"); err != nil { t.Fatalf("SetActivityProperty PAGE failed: %v", err) } actDoc, _ := m.findActivityByCaption("Review", 0) taskPage := dGetDoc(actDoc, "TaskPage") - // This documents the bug: TaskPage is nil because dSet can't create new keys - if taskPage != nil { - t.Log("BUG FIXED: TaskPage is now set even when key was absent") + if taskPage == nil { + t.Fatal("TaskPage should be set even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.TaskPage" { + t.Errorf("Page = %q, want MyModule.TaskPage", got) } } From 7047e58c532f25371ccd517816f30f9fcb0209fa Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:24:37 +0200 Subject: [PATCH 2/7] test: add nested sub-flow test for replaceActivity recursive path --- mdl/backend/mpr/backend.go | 18 ++++++++----- mdl/backend/mpr/convert_roundtrip_test.go | 1 - mdl/backend/mpr/workflow_mutator_test.go | 33 +++++++++++++++++++++++ mdl/executor/widget_registry.go | 4 +-- mdl/types/edmx_test.go | 6 ++--- mdl/types/id_test.go | 2 +- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/mdl/backend/mpr/backend.go b/mdl/backend/mpr/backend.go index da20193b..48e5426b 100644 --- a/mdl/backend/mpr/backend.go +++ b/mdl/backend/mpr/backend.go @@ -85,9 +85,11 @@ func (b *MprBackend) Path() string { return b.path } // for new code. func (b *MprBackend) MprReader() *mpr.Reader { return b.reader } -func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) } -func (b *MprBackend) ProjectVersion() *types.ProjectVersion { return convertProjectVersion(b.reader.ProjectVersion()) } -func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() } +func (b *MprBackend) Version() types.MPRVersion { return convertMPRVersion(b.reader.Version()) } +func (b *MprBackend) ProjectVersion() *types.ProjectVersion { + return convertProjectVersion(b.reader.ProjectVersion()) +} +func (b *MprBackend) GetMendixVersion() (string, error) { return b.reader.GetMendixVersion() } // Commit is a no-op — the MPR writer auto-commits on each write operation. func (b *MprBackend) Commit() error { return nil } @@ -112,7 +114,9 @@ func (b *MprBackend) DeleteModuleWithCleanup(id model.ID, moduleName string) err // FolderBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { return convertFolderInfoSlice(b.reader.ListFolders()) } +func (b *MprBackend) ListFolders() ([]*types.FolderInfo, error) { + return convertFolderInfoSlice(b.reader.ListFolders()) +} func (b *MprBackend) CreateFolder(folder *model.Folder) error { return b.writer.CreateFolder(folder) } func (b *MprBackend) DeleteFolder(id model.ID) error { return b.writer.DeleteFolder(id) } func (b *MprBackend) MoveFolder(id model.ID, newContainerID model.ID) error { @@ -678,8 +682,10 @@ func (b *MprBackend) UpdateRawUnit(unitID string, contents []byte) error { // MetadataBackend // --------------------------------------------------------------------------- -func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } -func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { return convertUnitInfoSlice(b.reader.ListUnits()) } +func (b *MprBackend) ListAllUnitIDs() ([]string, error) { return b.reader.ListAllUnitIDs() } +func (b *MprBackend) ListUnits() ([]*types.UnitInfo, error) { + return convertUnitInfoSlice(b.reader.ListUnits()) +} func (b *MprBackend) GetUnitTypes() (map[string]int, error) { return b.reader.GetUnitTypes() } func (b *MprBackend) GetProjectRootID() (string, error) { return b.reader.GetProjectRootID() } func (b *MprBackend) ContentsDir() string { return b.reader.ContentsDir() } diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go index a10391fc..157572f0 100644 --- a/mdl/backend/mpr/convert_roundtrip_test.go +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -648,4 +648,3 @@ func TestFieldCountDrift(t *testing.T) { assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6) assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6) } - diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index f6378558..aa42a5ec 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1176,6 +1176,39 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { } } +func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) { + // Exercises the recursive replaceActivity path: the target activity lives + // inside an outcome's sub-flow, not at the top level. + nestedAct := makeWfActivity("Workflows$UserTask", "NestedReview", "nested1") + // No TaskPage field at all on the nested activity. + + outcome := bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$BooleanOutcome"}, + {Key: "Flow", Value: bson.D{ + {Key: "$ID", Value: primitive.Binary{Subtype: 0x04, Data: make([]byte, 16)}}, + {Key: "$Type", Value: "Workflows$Flow"}, + {Key: "Activities", Value: bson.A{int32(3), nestedAct}}, + }}, + } + parentAct := makeWfActivity("Workflows$Decision", "Check", "decision1") + parentAct = append(parentAct, bson.E{Key: "Outcomes", Value: bson.A{int32(3), outcome}}) + m := newMutator(makeWorkflowDoc(parentAct)) + + if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil { + t.Fatalf("SetActivityProperty PAGE on nested activity failed: %v", err) + } + + actDoc, _ := m.findActivityByCaption("NestedReview", 0) + taskPage := dGetDoc(actDoc, "TaskPage") + if taskPage == nil { + t.Fatal("TaskPage should be set on nested activity even when key was absent") + } + if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" { + t.Errorf("Page = %q, want MyModule.NestedPage", got) + } +} + func TestWorkflowMutator_SetActivityProperty_Page_Existing(t *testing.T) { act := makeWfActivity("Workflows$UserTask", "Review", "task1") act = append(act, bson.E{Key: "TaskPage", Value: bson.D{ diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index 71192c50..f65e9b8d 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -16,8 +16,8 @@ import ( // WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name. type WidgetRegistry struct { - byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName - byWidgetID map[string]*WidgetDefinition // keyed by widgetId + byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName + byWidgetID map[string]*WidgetDefinition // keyed by widgetId knownOperations map[string]bool // operations accepted during validation } diff --git a/mdl/types/edmx_test.go b/mdl/types/edmx_test.go index a4159e93..01d2b374 100644 --- a/mdl/types/edmx_test.go +++ b/mdl/types/edmx_test.go @@ -217,9 +217,9 @@ func TestFindEntityType(t *testing.T) { func TestResolveNavType(t *testing.T) { tests := []struct { - input string - typeName string - isMany bool + input string + typeName string + isMany bool }{ {"Collection(NS.Order)", "Order", true}, {"NS.Customer", "Customer", false}, diff --git a/mdl/types/id_test.go b/mdl/types/id_test.go index d30a5e39..aa138154 100644 --- a/mdl/types/id_test.go +++ b/mdl/types/id_test.go @@ -156,7 +156,7 @@ func TestValidateID(t *testing.T) { {"AABBCCDD-EEFF-1122-3344-556677889900", true}, {"", false}, {"too-short", false}, - {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars + {"a1b2c3d4-e5f6-7890-abcd-ef123456789", false}, // 35 chars {"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // 37 chars {"a1b2c3d4xe5f6-7890-abcd-ef1234567890", false}, // wrong separator {"g1b2c3d4-e5f6-7890-abcd-ef1234567890", false}, // invalid hex From 767b9f8397040a239c8d913f95a043b8d9910e62 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:37:35 +0200 Subject: [PATCH 3/7] test: use distinct IDs in nested sub-flow test to ensure recursive path --- mdl/backend/mpr/workflow_mutator_test.go | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator_test.go b/mdl/backend/mpr/workflow_mutator_test.go index aa42a5ec..6ffec5e0 100644 --- a/mdl/backend/mpr/workflow_mutator_test.go +++ b/mdl/backend/mpr/workflow_mutator_test.go @@ -1179,7 +1179,16 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey(t *testing.T) { func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *testing.T) { // Exercises the recursive replaceActivity path: the target activity lives // inside an outcome's sub-flow, not at the top level. - nestedAct := makeWfActivity("Workflows$UserTask", "NestedReview", "nested1") + // Use distinct $IDs so replaceActivity cannot accidentally match the parent. + parentID := primitive.Binary{Subtype: 0x04, Data: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + nestedID := primitive.Binary{Subtype: 0x04, Data: []byte{2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + + nestedAct := bson.D{ + {Key: "$ID", Value: nestedID}, + {Key: "$Type", Value: "Workflows$UserTask"}, + {Key: "Caption", Value: "NestedReview"}, + {Key: "Name", Value: "nested1"}, + } // No TaskPage field at all on the nested activity. outcome := bson.D{ @@ -1191,8 +1200,13 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *te {Key: "Activities", Value: bson.A{int32(3), nestedAct}}, }}, } - parentAct := makeWfActivity("Workflows$Decision", "Check", "decision1") - parentAct = append(parentAct, bson.E{Key: "Outcomes", Value: bson.A{int32(3), outcome}}) + parentAct := bson.D{ + {Key: "$ID", Value: parentID}, + {Key: "$Type", Value: "Workflows$Decision"}, + {Key: "Caption", Value: "Check"}, + {Key: "Name", Value: "decision1"}, + {Key: "Outcomes", Value: bson.A{int32(3), outcome}}, + } m := newMutator(makeWorkflowDoc(parentAct)) if err := m.SetActivityProperty("NestedReview", 0, "PAGE", "MyModule.NestedPage"); err != nil { @@ -1207,6 +1221,15 @@ func TestWorkflowMutator_SetActivityProperty_Page_MissingKey_NestedSubFlow(t *te if got := dGetString(taskPage, "Page"); got != "MyModule.NestedPage" { t.Errorf("Page = %q, want MyModule.NestedPage", got) } + + // Verify parent decision still has its Outcomes intact. + parentDoc, _ := m.findActivityByCaption("Check", 0) + if parentDoc == nil { + t.Fatal("parent decision activity should still exist") + } + if outcomes := dGet(parentDoc, "Outcomes"); outcomes == nil { + t.Fatal("parent decision Outcomes should still be present") + } } func TestWorkflowMutator_SetActivityProperty_Page_Existing(t *testing.T) { From cddd4222d5745f8c3a9b353d834bb275d9932d79 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:43:09 +0200 Subject: [PATCH 4/7] refactor: use dGetArrayElements in replaceActivityRecursive to reduce duplication --- mdl/backend/mpr/workflow_mutator.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index 74926e89..e888d530 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -579,25 +579,14 @@ func (m *mprWorkflowMutator) replaceActivity(updated bson.D) { } func replaceActivityRecursive(flow bson.D, actID string, updated bson.D) bool { - activitiesVal := dGet(flow, "Activities") - arr := toBsonA(activitiesVal) - if len(arr) == 0 { - return false - } - // Skip the int32 type marker at index 0 if present. - start := 0 - if _, ok := arr[0].(int32); ok { - start = 1 - } else if _, ok := arr[0].(int); ok { - start = 1 - } - for i := start; i < len(arr); i++ { - actDoc, ok := arr[i].(bson.D) + elements := dGetArrayElements(dGet(flow, "Activities")) + for i, elem := range elements { + actDoc, ok := elem.(bson.D) if !ok { continue } if extractBinaryIDFromDoc(dGet(actDoc, "$ID")) == actID { - arr[i] = updated + elements[i] = updated return true } for _, nestedFlow := range getNestedFlows(actDoc) { From 34d7a58fa43cf71e180ae237e046fe486470203f Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:48:15 +0200 Subject: [PATCH 5/7] style: invert dSet condition to eliminate empty branch --- mdl/backend/mpr/workflow_mutator.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mdl/backend/mpr/workflow_mutator.go b/mdl/backend/mpr/workflow_mutator.go index e888d530..5a1b78ba 100644 --- a/mdl/backend/mpr/workflow_mutator.go +++ b/mdl/backend/mpr/workflow_mutator.go @@ -171,9 +171,7 @@ func (m *mprWorkflowMutator) SetActivityProperty(activityRef string, atPos int, {Key: "$Type", Value: "Workflows$PageReference"}, {Key: "Page", Value: value}, } - if dSet(actDoc, "TaskPage", pageRef) { - // TaskPage key exists (nil value) — replaced in place via dSet. - } else { + if !dSet(actDoc, "TaskPage", pageRef) { // TaskPage key absent — append to activity and replace in BSON tree. actDoc = append(actDoc, bson.E{Key: "TaskPage", Value: pageRef}) m.replaceActivity(actDoc) From d52a43c1e0538b153d41b85a5811a0a3d883b85a Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 12:56:18 +0200 Subject: [PATCH 6/7] Add MockPageMutator, MockWorkflowMutator, and fix registry completeness test Add mock implementations of PageMutator (16 methods) and WorkflowMutator (15 methods) using the same Func-field delegation pattern as MockBackend. Both include compile-time interface checks. Fix allKnownStatements() to include 8 missing agent editor statement types (CreateAgent, DropAgent, CreateModel, DropModel, CreateConsumedMCPService, DropConsumedMCPService, CreateKnowledgeBase, DropKnowledgeBase). Add handler count snapshot test. --- mdl/backend/mock/mock_page_mutator.go | 145 ++++++++++++++++++++++ mdl/backend/mock/mock_workflow_mutator.go | 136 ++++++++++++++++++++ mdl/executor/registry_test.go | 19 +++ 3 files changed, 300 insertions(+) create mode 100644 mdl/backend/mock/mock_page_mutator.go create mode 100644 mdl/backend/mock/mock_workflow_mutator.go diff --git a/mdl/backend/mock/mock_page_mutator.go b/mdl/backend/mock/mock_page_mutator.go new file mode 100644 index 00000000..868a97e8 --- /dev/null +++ b/mdl/backend/mock/mock_page_mutator.go @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +var _ backend.PageMutator = (*MockPageMutator)(nil) + +// MockPageMutator implements backend.PageMutator. Every interface method is +// backed by a public function field. If the field is nil the method returns +// zero values / nil error (never panics). +type MockPageMutator struct { + ContainerTypeFunc func() backend.ContainerKind + SetWidgetPropertyFunc func(widgetRef string, prop string, value any) error + SetWidgetDataSourceFunc func(widgetRef string, ds pages.DataSource) error + SetColumnPropertyFunc func(gridRef string, columnRef string, prop string, value any) error + InsertWidgetFunc func(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error + DropWidgetFunc func(refs []backend.WidgetRef) error + ReplaceWidgetFunc func(widgetRef string, columnRef string, widgets []pages.Widget) error + FindWidgetFunc func(name string) bool + AddVariableFunc func(name, dataType, defaultValue string) error + DropVariableFunc func(name string) error + SetLayoutFunc func(newLayout string, paramMappings map[string]string) error + SetPluggablePropertyFunc func(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error + EnclosingEntityFunc func(widgetRef string) string + WidgetScopeFunc func() map[string]model.ID + ParamScopeFunc func() (map[string]model.ID, map[string]string) + SaveFunc func() error +} + +func (m *MockPageMutator) ContainerType() backend.ContainerKind { + if m.ContainerTypeFunc != nil { + return m.ContainerTypeFunc() + } + return backend.ContainerPage +} + +func (m *MockPageMutator) SetWidgetProperty(widgetRef string, prop string, value any) error { + if m.SetWidgetPropertyFunc != nil { + return m.SetWidgetPropertyFunc(widgetRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) SetWidgetDataSource(widgetRef string, ds pages.DataSource) error { + if m.SetWidgetDataSourceFunc != nil { + return m.SetWidgetDataSourceFunc(widgetRef, ds) + } + return nil +} + +func (m *MockPageMutator) SetColumnProperty(gridRef string, columnRef string, prop string, value any) error { + if m.SetColumnPropertyFunc != nil { + return m.SetColumnPropertyFunc(gridRef, columnRef, prop, value) + } + return nil +} + +func (m *MockPageMutator) InsertWidget(widgetRef string, columnRef string, position backend.InsertPosition, widgets []pages.Widget) error { + if m.InsertWidgetFunc != nil { + return m.InsertWidgetFunc(widgetRef, columnRef, position, widgets) + } + return nil +} + +func (m *MockPageMutator) DropWidget(refs []backend.WidgetRef) error { + if m.DropWidgetFunc != nil { + return m.DropWidgetFunc(refs) + } + return nil +} + +func (m *MockPageMutator) ReplaceWidget(widgetRef string, columnRef string, widgets []pages.Widget) error { + if m.ReplaceWidgetFunc != nil { + return m.ReplaceWidgetFunc(widgetRef, columnRef, widgets) + } + return nil +} + +func (m *MockPageMutator) FindWidget(name string) bool { + if m.FindWidgetFunc != nil { + return m.FindWidgetFunc(name) + } + return false +} + +func (m *MockPageMutator) AddVariable(name, dataType, defaultValue string) error { + if m.AddVariableFunc != nil { + return m.AddVariableFunc(name, dataType, defaultValue) + } + return nil +} + +func (m *MockPageMutator) DropVariable(name string) error { + if m.DropVariableFunc != nil { + return m.DropVariableFunc(name) + } + return nil +} + +func (m *MockPageMutator) SetLayout(newLayout string, paramMappings map[string]string) error { + if m.SetLayoutFunc != nil { + return m.SetLayoutFunc(newLayout, paramMappings) + } + return nil +} + +func (m *MockPageMutator) SetPluggableProperty(widgetRef string, propKey string, op backend.PluggablePropertyOp, ctx backend.PluggablePropertyContext) error { + if m.SetPluggablePropertyFunc != nil { + return m.SetPluggablePropertyFunc(widgetRef, propKey, op, ctx) + } + return nil +} + +func (m *MockPageMutator) EnclosingEntity(widgetRef string) string { + if m.EnclosingEntityFunc != nil { + return m.EnclosingEntityFunc(widgetRef) + } + return "" +} + +func (m *MockPageMutator) WidgetScope() map[string]model.ID { + if m.WidgetScopeFunc != nil { + return m.WidgetScopeFunc() + } + return nil +} + +func (m *MockPageMutator) ParamScope() (map[string]model.ID, map[string]string) { + if m.ParamScopeFunc != nil { + return m.ParamScopeFunc() + } + return nil, nil +} + +func (m *MockPageMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/backend/mock/mock_workflow_mutator.go b/mdl/backend/mock/mock_workflow_mutator.go new file mode 100644 index 00000000..b184953b --- /dev/null +++ b/mdl/backend/mock/mock_workflow_mutator.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mock + +import ( + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +var _ backend.WorkflowMutator = (*MockWorkflowMutator)(nil) + +// MockWorkflowMutator implements backend.WorkflowMutator. Every interface +// method is backed by a public function field. If the field is nil the +// method returns zero values / nil error (never panics). +type MockWorkflowMutator struct { + SetPropertyFunc func(prop string, value string) error + SetPropertyWithEntityFunc func(prop string, value string, entity string) error + SetActivityPropertyFunc func(activityRef string, atPos int, prop string, value string) error + InsertAfterActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + DropActivityFunc func(activityRef string, atPos int) error + ReplaceActivityFunc func(activityRef string, atPos int, activities []workflows.WorkflowActivity) error + InsertOutcomeFunc func(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error + DropOutcomeFunc func(activityRef string, atPos int, outcomeName string) error + InsertPathFunc func(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error + DropPathFunc func(activityRef string, atPos int, pathCaption string) error + InsertBranchFunc func(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error + DropBranchFunc func(activityRef string, atPos int, branchName string) error + InsertBoundaryEventFunc func(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error + DropBoundaryEventFunc func(activityRef string, atPos int) error + SaveFunc func() error +} + +func (m *MockWorkflowMutator) SetProperty(prop string, value string) error { + if m.SetPropertyFunc != nil { + return m.SetPropertyFunc(prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) SetPropertyWithEntity(prop string, value string, entity string) error { + if m.SetPropertyWithEntityFunc != nil { + return m.SetPropertyWithEntityFunc(prop, value, entity) + } + return nil +} + +func (m *MockWorkflowMutator) SetActivityProperty(activityRef string, atPos int, prop string, value string) error { + if m.SetActivityPropertyFunc != nil { + return m.SetActivityPropertyFunc(activityRef, atPos, prop, value) + } + return nil +} + +func (m *MockWorkflowMutator) InsertAfterActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.InsertAfterActivityFunc != nil { + return m.InsertAfterActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropActivity(activityRef string, atPos int) error { + if m.DropActivityFunc != nil { + return m.DropActivityFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) ReplaceActivity(activityRef string, atPos int, activities []workflows.WorkflowActivity) error { + if m.ReplaceActivityFunc != nil { + return m.ReplaceActivityFunc(activityRef, atPos, activities) + } + return nil +} + +func (m *MockWorkflowMutator) InsertOutcome(activityRef string, atPos int, outcomeName string, activities []workflows.WorkflowActivity) error { + if m.InsertOutcomeFunc != nil { + return m.InsertOutcomeFunc(activityRef, atPos, outcomeName, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropOutcome(activityRef string, atPos int, outcomeName string) error { + if m.DropOutcomeFunc != nil { + return m.DropOutcomeFunc(activityRef, atPos, outcomeName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertPath(activityRef string, atPos int, pathCaption string, activities []workflows.WorkflowActivity) error { + if m.InsertPathFunc != nil { + return m.InsertPathFunc(activityRef, atPos, pathCaption, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropPath(activityRef string, atPos int, pathCaption string) error { + if m.DropPathFunc != nil { + return m.DropPathFunc(activityRef, atPos, pathCaption) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBranch(activityRef string, atPos int, condition string, activities []workflows.WorkflowActivity) error { + if m.InsertBranchFunc != nil { + return m.InsertBranchFunc(activityRef, atPos, condition, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBranch(activityRef string, atPos int, branchName string) error { + if m.DropBranchFunc != nil { + return m.DropBranchFunc(activityRef, atPos, branchName) + } + return nil +} + +func (m *MockWorkflowMutator) InsertBoundaryEvent(activityRef string, atPos int, eventType string, delay string, activities []workflows.WorkflowActivity) error { + if m.InsertBoundaryEventFunc != nil { + return m.InsertBoundaryEventFunc(activityRef, atPos, eventType, delay, activities) + } + return nil +} + +func (m *MockWorkflowMutator) DropBoundaryEvent(activityRef string, atPos int) error { + if m.DropBoundaryEventFunc != nil { + return m.DropBoundaryEventFunc(activityRef, atPos) + } + return nil +} + +func (m *MockWorkflowMutator) Save() error { + if m.SaveFunc != nil { + return m.SaveFunc() + } + return nil +} diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index 4641412f..ac7957b0 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -172,9 +172,11 @@ func allKnownStatements() []ast.Statement { &ast.AlterUserRoleStmt{}, &ast.AlterWorkflowStmt{}, &ast.ConnectStmt{}, + &ast.CreateAgentStmt{}, &ast.CreateAssociationStmt{}, &ast.CreateBusinessEventServiceStmt{}, &ast.CreateConfigurationStmt{}, + &ast.CreateConsumedMCPServiceStmt{}, &ast.CreateConstantStmt{}, &ast.CreateDatabaseConnectionStmt{}, &ast.CreateDataTransformerStmt{}, @@ -188,7 +190,9 @@ func allKnownStatements() []ast.Statement { &ast.CreateImportMappingStmt{}, &ast.CreateJavaActionStmt{}, &ast.CreateJsonStructureStmt{}, + &ast.CreateKnowledgeBaseStmt{}, &ast.CreateMicroflowStmt{}, + &ast.CreateModelStmt{}, &ast.CreateModuleRoleStmt{}, &ast.CreateModuleStmt{}, &ast.CreateODataClientStmt{}, @@ -206,9 +210,11 @@ func allKnownStatements() []ast.Statement { &ast.DescribeStmt{}, &ast.DescribeStylingStmt{}, &ast.DisconnectStmt{}, + &ast.DropAgentStmt{}, &ast.DropAssociationStmt{}, &ast.DropBusinessEventServiceStmt{}, &ast.DropConfigurationStmt{}, + &ast.DropConsumedMCPServiceStmt{}, &ast.DropConstantStmt{}, &ast.DropDataTransformerStmt{}, &ast.DropDemoUserStmt{}, @@ -220,7 +226,9 @@ func allKnownStatements() []ast.Statement { &ast.DropImportMappingStmt{}, &ast.DropJavaActionStmt{}, &ast.DropJsonStructureStmt{}, + &ast.DropKnowledgeBaseStmt{}, &ast.DropMicroflowStmt{}, + &ast.DropModelStmt{}, &ast.DropModuleRoleStmt{}, &ast.DropModuleStmt{}, &ast.DropODataClientStmt{}, @@ -286,3 +294,14 @@ func TestNewRegistry_Completeness(t *testing.T) { t.Fatalf("registry is incomplete: %v", err) } } + +// TestNewRegistry_HandlerCountSnapshot validates the expected number of +// registered handlers. Update the expected count when adding new handlers. +func TestNewRegistry_HandlerCountSnapshot(t *testing.T) { + r := NewRegistry() + known := allKnownStatements() + + if got := r.HandlerCount(); got != len(known) { + t.Errorf("handler count mismatch: registry has %d, allKnownStatements has %d — update allKnownStatements or register missing handlers", got, len(known)) + } +} From 4d970a6e365455abfb9c08c637b5c72e769729c9 Mon Sep 17 00:00:00 2001 From: Andrew Vasilyev Date: Tue, 21 Apr 2026 13:01:24 +0200 Subject: [PATCH 7/7] Clarify handler count snapshot test comment per review --- mdl/executor/registry_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mdl/executor/registry_test.go b/mdl/executor/registry_test.go index ac7957b0..932e441b 100644 --- a/mdl/executor/registry_test.go +++ b/mdl/executor/registry_test.go @@ -295,8 +295,9 @@ func TestNewRegistry_Completeness(t *testing.T) { } } -// TestNewRegistry_HandlerCountSnapshot validates the expected number of -// registered handlers. Update the expected count when adding new handlers. +// TestNewRegistry_HandlerCountSnapshot verifies that the number of registered +// handlers matches allKnownStatements(). Keep allKnownStatements() in sync with +// known statement types and handler registrations. func TestNewRegistry_HandlerCountSnapshot(t *testing.T) { r := NewRegistry() known := allKnownStatements()