diff --git a/mdl/executor/cmd_javaactions_mock_test.go b/mdl/executor/cmd_javaactions_mock_test.go index 5cf56dcf..aaf727d9 100644 --- a/mdl/executor/cmd_javaactions_mock_test.go +++ b/mdl/executor/cmd_javaactions_mock_test.go @@ -59,9 +59,11 @@ func TestDescribeJavaAction_Mock(t *testing.T) { } // NOTE: listJavaActions has no explicit not-connected guard. It calls -// getHierarchy (returns nil when disconnected) then proceeds to call -// ListJavaActions on the backend. The handler degrades gracefully — -// with an empty result set it succeeds with a nil hierarchy. +// getHierarchy (which returns nil when disconnected) and is intended to +// be reached through execShow, which enforces a connected backend first. +// A nil hierarchy is only harmless when the backend returns no Java +// actions; if Java actions are returned while disconnected, dereferencing +// the nil hierarchy would panic. func TestShowJavaActions_BackendError(t *testing.T) { mod := mkModule("MyModule") diff --git a/mdl/linter/rules/conv_error_handling_test.go b/mdl/linter/rules/conv_error_handling_test.go new file mode 100644 index 00000000..45b57cf2 --- /dev/null +++ b/mdl/linter/rules/conv_error_handling_test.go @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestFindUnhandledCalls_RestCallNoCustom(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeAbort, + }, + Action: µflows.RestCallAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "CONV013" { + t.Errorf("expected CONV013, got %s", violations[0].RuleID) + } +} + +func TestFindUnhandledCalls_RestCallCustom(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeCustom, + }, + Action: µflows.RestCallAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations with Custom handling, got %d", len(violations)) + } +} + +func TestFindUnhandledCalls_CustomWithoutRollback(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeCustomWithoutRollback, + }, + Action: µflows.RestCallAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations with CustomWithoutRollback, got %d", len(violations)) + } +} + +func TestFindUnhandledCalls_JavaAction(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeAbort, + }, + Action: µflows.JavaActionCallAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation for Java action, got %d", len(violations)) + } +} + +func TestFindUnhandledCalls_WebServiceCall(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeContinue, + }, + Action: µflows.WebServiceCallAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation for WS call, got %d", len(violations)) + } + if violations[0].RuleID != "CONV013" { + t.Errorf("expected CONV013, got %s", violations[0].RuleID) + } +} + +func TestFindUnhandledCalls_NonExternalAction(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeAbort, + }, + Action: µflows.CommitObjectsAction{}, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for non-external action, got %d", len(violations)) + } +} + +func TestFindUnhandledCalls_InsideLoop(t *testing.T) { + loopBody := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeAbort, + }, + Action: µflows.RestCallAction{}, + }, + }, + } + objects := []microflows.MicroflowObject{ + µflows.LoopedActivity{ + ObjectCollection: loopBody, + }, + } + + var violations []linter.Violation + r := NewErrorHandlingOnCallsRule() + findUnhandledCalls(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Errorf("expected 1 violation inside loop, got %d", len(violations)) + } +} + +// --- CONV014 tests --- + +func TestFindContinueErrorHandling_Activity(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + Caption: "Do something", + ErrorHandlingType: microflows.ErrorHandlingTypeContinue, + }, + Action: µflows.CommitObjectsAction{}, + }, + } + + var violations []linter.Violation + r := NewNoContinueErrorHandlingRule() + findContinueErrorHandling(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "CONV014" { + t.Errorf("expected CONV014, got %s", violations[0].RuleID) + } +} + +func TestFindContinueErrorHandling_Loop(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.LoopedActivity{ + Caption: "Process items", + ErrorHandlingType: microflows.ErrorHandlingTypeContinue, + }, + } + + var violations []linter.Violation + r := NewNoContinueErrorHandlingRule() + findContinueErrorHandling(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation for loop, got %d", len(violations)) + } +} + +func TestFindContinueErrorHandling_AbortIsOk(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{ + ErrorHandlingType: microflows.ErrorHandlingTypeAbort, + }, + Action: µflows.CommitObjectsAction{}, + }, + } + + var violations []linter.Violation + r := NewNoContinueErrorHandlingRule() + findContinueErrorHandling(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for Abort, got %d", len(violations)) + } +} + +func TestErrorHandlingOnCallsRule_Metadata(t *testing.T) { + r := NewErrorHandlingOnCallsRule() + if r.ID() != "CONV013" { + t.Errorf("ID = %q, want CONV013", r.ID()) + } +} + +func TestNoContinueErrorHandlingRule_Metadata(t *testing.T) { + r := NewNoContinueErrorHandlingRule() + if r.ID() != "CONV014" { + t.Errorf("ID = %q, want CONV014", r.ID()) + } +} diff --git a/mdl/linter/rules/conv_loop_commit_test.go b/mdl/linter/rules/conv_loop_commit_test.go new file mode 100644 index 00000000..44c1de8e --- /dev/null +++ b/mdl/linter/rules/conv_loop_commit_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestFindCommitsInLoops_NoLoop(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: µflows.CommitObjectsAction{}, + }, + } + + var violations []linter.Violation + r := NewNoCommitInLoopRule() + findCommitsInLoops(objects, testMicroflow(), r, &violations, false) + + if len(violations) != 0 { + t.Errorf("expected 0 violations outside loop, got %d", len(violations)) + } +} + +func TestFindCommitsInLoops_CommitInsideLoop(t *testing.T) { + loopBody := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: µflows.CommitObjectsAction{}, + }, + }, + } + objects := []microflows.MicroflowObject{ + µflows.LoopedActivity{ + ObjectCollection: loopBody, + }, + } + + var violations []linter.Violation + r := NewNoCommitInLoopRule() + findCommitsInLoops(objects, testMicroflow(), r, &violations, false) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "CONV011" { + t.Errorf("expected CONV011, got %s", violations[0].RuleID) + } +} + +func TestFindCommitsInLoops_NilAction(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: nil, + }, + } + + var violations []linter.Violation + r := NewNoCommitInLoopRule() + findCommitsInLoops(objects, testMicroflow(), r, &violations, true) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for nil action, got %d", len(violations)) + } +} + +func TestNoCommitInLoopRule_NilReader(t *testing.T) { + r := NewNoCommitInLoopRule() + ctx := linter.NewLintContextFromDB(nil) + // Reader() is nil, should return nil + violations := r.Check(ctx) + if violations != nil { + t.Errorf("expected nil with nil reader, got %v", violations) + } +} + +func TestNoCommitInLoopRule_Metadata(t *testing.T) { + r := NewNoCommitInLoopRule() + if r.ID() != "CONV011" { + t.Errorf("ID = %q, want CONV011", r.ID()) + } + if r.Category() != "performance" { + t.Errorf("Category = %q, want performance", r.Category()) + } +} diff --git a/mdl/linter/rules/conv_split_caption_test.go b/mdl/linter/rules/conv_split_caption_test.go new file mode 100644 index 00000000..48fdcaf6 --- /dev/null +++ b/mdl/linter/rules/conv_split_caption_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestFindEmptySplitCaptions_EmptyCaption(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ExclusiveSplit{Caption: ""}, + } + + var violations []linter.Violation + r := NewExclusiveSplitCaptionRule() + findEmptySplitCaptions(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "CONV012" { + t.Errorf("expected CONV012, got %s", violations[0].RuleID) + } +} + +func TestFindEmptySplitCaptions_WithCaption(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ExclusiveSplit{Caption: "Is order valid?"}, + } + + var violations []linter.Violation + r := NewExclusiveSplitCaptionRule() + findEmptySplitCaptions(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d", len(violations)) + } +} + +func TestFindEmptySplitCaptions_WhitespaceOnly(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ExclusiveSplit{Caption: " "}, + } + + var violations []linter.Violation + r := NewExclusiveSplitCaptionRule() + findEmptySplitCaptions(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Errorf("expected 1 violation for whitespace caption, got %d", len(violations)) + } +} + +func TestFindEmptySplitCaptions_InsideLoop(t *testing.T) { + loopBody := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ExclusiveSplit{Caption: ""}, + }, + } + objects := []microflows.MicroflowObject{ + µflows.LoopedActivity{ + ObjectCollection: loopBody, + }, + } + + var violations []linter.Violation + r := NewExclusiveSplitCaptionRule() + findEmptySplitCaptions(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Errorf("expected 1 violation inside loop, got %d", len(violations)) + } +} + +func TestExclusiveSplitCaptionRule_Metadata(t *testing.T) { + r := NewExclusiveSplitCaptionRule() + if r.ID() != "CONV012" { + t.Errorf("ID = %q, want CONV012", r.ID()) + } + if r.Category() != "quality" { + t.Errorf("Category = %q, want quality", r.Category()) + } +} diff --git a/mdl/linter/rules/domain_size_test.go b/mdl/linter/rules/domain_size_test.go new file mode 100644 index 00000000..8b4736e6 --- /dev/null +++ b/mdl/linter/rules/domain_size_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +func TestDomainModelSizeRule_NoViolation(t *testing.T) { + var entities [][]any + for i := 0; i < 10; i++ { + entities = append(entities, []any{ + fmt.Sprintf("id%d", i), fmt.Sprintf("Entity%d", i), + fmt.Sprintf("MyModule.Entity%d", i), "MyModule", "", + "PERSISTENT", "", "", 5, 1, 0, 0, 0, + }) + } + + db := setupEntitiesDB(t, entities) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewDomainModelSizeRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for 10 entities, got %d", len(violations)) + } +} + +func TestDomainModelSizeRule_ExceedsThreshold(t *testing.T) { + var entities [][]any + for i := 0; i < 20; i++ { + entities = append(entities, []any{ + fmt.Sprintf("id%d", i), fmt.Sprintf("Entity%d", i), + fmt.Sprintf("BigModule.Entity%d", i), "BigModule", "", + "PERSISTENT", "", "", 3, 1, 0, 0, 0, + }) + } + + db := setupEntitiesDB(t, entities) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewDomainModelSizeRule() + violations := rule.Check(ctx) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "MPR003" { + t.Errorf("expected rule ID MPR003, got %s", violations[0].RuleID) + } +} + +func TestDomainModelSizeRule_NonPersistentIgnored(t *testing.T) { + var entities [][]any + for i := 0; i < 20; i++ { + entities = append(entities, []any{ + fmt.Sprintf("id%d", i), fmt.Sprintf("Entity%d", i), + fmt.Sprintf("MyModule.Entity%d", i), "MyModule", "", + "NON_PERSISTENT", "", "", 3, 0, 0, 0, 0, + }) + } + + db := setupEntitiesDB(t, entities) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewDomainModelSizeRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for non-persistent entities, got %d", len(violations)) + } +} + +func TestDomainModelSizeRule_ExactlyAtThreshold(t *testing.T) { + var entities [][]any + for i := 0; i < DefaultMaxPersistentEntities; i++ { + entities = append(entities, []any{ + fmt.Sprintf("id%d", i), fmt.Sprintf("Entity%d", i), + fmt.Sprintf("MyModule.Entity%d", i), "MyModule", "", + "PERSISTENT", "", "", 3, 1, 0, 0, 0, + }) + } + + db := setupEntitiesDB(t, entities) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewDomainModelSizeRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations at threshold (%d entities), got %d", DefaultMaxPersistentEntities, len(violations)) + } +} + +func TestDomainModelSizeRule_OneOverThreshold(t *testing.T) { + count := DefaultMaxPersistentEntities + 1 + var entities [][]any + for i := 0; i < count; i++ { + entities = append(entities, []any{ + fmt.Sprintf("id%d", i), fmt.Sprintf("Entity%d", i), + fmt.Sprintf("MyModule.Entity%d", i), "MyModule", "", + "PERSISTENT", "", "", 3, 1, 0, 0, 0, + }) + } + + db := setupEntitiesDB(t, entities) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewDomainModelSizeRule() + violations := rule.Check(ctx) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation at %d entities, got %d", count, len(violations)) + } +} + +func TestDomainModelSizeRule_Metadata(t *testing.T) { + r := NewDomainModelSizeRule() + if r.ID() != "MPR003" { + t.Errorf("ID = %q, want MPR003", r.ID()) + } + if r.Category() != "design" { + t.Errorf("Category = %q, want design", r.Category()) + } + if r.MaxPersistentEntities != DefaultMaxPersistentEntities { + t.Errorf("MaxPersistentEntities = %d, want %d", r.MaxPersistentEntities, DefaultMaxPersistentEntities) + } +} diff --git a/mdl/linter/rules/empty_container_test.go b/mdl/linter/rules/empty_container_test.go new file mode 100644 index 00000000..66f41814 --- /dev/null +++ b/mdl/linter/rules/empty_container_test.go @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import "testing" + +func TestFindEmptyContainers_PageWithEmpty(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "emptyDiv", + "Widgets": []any{}, + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 1 { + t.Fatalf("expected 1 empty container, got %d", len(result)) + } + if result[0].Name != "emptyDiv" { + t.Errorf("expected name 'emptyDiv', got %q", result[0].Name) + } +} + +func TestFindEmptyContainers_PageWithChildren(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "filledDiv", + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$TextBox", + "Name": "txt1", + }, + }, + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 0 { + t.Errorf("expected 0 empty containers, got %d", len(result)) + } +} + +func TestFindEmptyContainers_SnippetStructure(t *testing.T) { + rawData := map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "snippetEmpty", + "Widgets": []any{}, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 1 { + t.Fatalf("expected 1 empty container, got %d", len(result)) + } + if result[0].Name != "snippetEmpty" { + t.Errorf("expected name 'snippetEmpty', got %q", result[0].Name) + } +} + +func TestFindEmptyContainers_NestedInLayoutGrid(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$LayoutGrid", + "Name": "grid1", + "Rows": []any{ + map[string]any{ + "Columns": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "nestedEmpty", + "Widgets": []any{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 1 { + t.Fatalf("expected 1 empty container, got %d", len(result)) + } + if result[0].Name != "nestedEmpty" { + t.Errorf("expected name 'nestedEmpty', got %q", result[0].Name) + } +} + +func TestFindEmptyContainers_NestedInTabContainer(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$TabContainer", + "Name": "tabs", + "TabPages": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "tabEmpty", + "Widgets": []any{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 1 { + t.Fatalf("expected 1 empty container, got %d", len(result)) + } + if result[0].Name != "tabEmpty" { + t.Errorf("expected name 'tabEmpty', got %q", result[0].Name) + } +} + +func TestFindEmptyContainers_NonContainer(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$TextBox", + "Name": "txt1", + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 0 { + t.Errorf("expected 0 empty containers, got %d", len(result)) + } +} + +func TestFindEmptyContainers_Multiple(t *testing.T) { + rawData := map[string]any{ + "FormCall": map[string]any{ + "Arguments": []any{ + map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "empty1", + "Widgets": []any{}, + }, + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "empty2", + "Widgets": []any{}, + }, + }, + }, + }, + }, + } + + result := findEmptyContainers(rawData) + if len(result) != 2 { + t.Fatalf("expected 2 empty containers, got %d", len(result)) + } +} + +func TestFindEmptyContainersRecursive_FooterWidgets(t *testing.T) { + w := map[string]any{ + "$Type": "Forms$SomeContainer", + "Name": "outer", + "FooterWidgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "footerEmpty", + "Widgets": []any{}, + }, + }, + } + + result := findEmptyContainersRecursive(w) + if len(result) != 1 { + t.Fatalf("expected 1 empty container, got %d", len(result)) + } + if result[0].Name != "footerEmpty" { + t.Errorf("expected name 'footerEmpty', got %q", result[0].Name) + } +} + +func TestEmptyContainerRule_Metadata(t *testing.T) { + r := NewEmptyContainerRule() + if r.ID() != "MPR006" { + t.Errorf("ID = %q, want MPR006", r.ID()) + } + if r.Category() != "correctness" { + t.Errorf("Category = %q, want correctness", r.Category()) + } + if r.Name() != "EmptyContainer" { + t.Errorf("Name = %q, want EmptyContainer", r.Name()) + } +} diff --git a/mdl/linter/rules/empty_test.go b/mdl/linter/rules/empty_test.go new file mode 100644 index 00000000..07cf0e16 --- /dev/null +++ b/mdl/linter/rules/empty_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "database/sql" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + + _ "modernc.org/sqlite" +) + +// setupMicroflowsDB creates an in-memory SQLite database with the microflows and modules tables. +// Each row is [Id, Name, QualifiedName, ModuleName, Folder, MicroflowType, Description, ReturnType, ParameterCount, ActivityCount, Complexity]. +func setupMicroflowsDB(t *testing.T, rows [][]any) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open in-memory db: %v", err) + } + + _, err = db.Exec(`CREATE TABLE modules (Name TEXT PRIMARY KEY, Source TEXT)`) + if err != nil { + t.Fatalf("failed to create modules table: %v", err) + } + + _, err = db.Exec(`CREATE TABLE microflows ( + Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + MicroflowType TEXT, Description TEXT, ReturnType TEXT, + ParameterCount INTEGER, ActivityCount INTEGER, Complexity INTEGER + )`) + if err != nil { + t.Fatalf("failed to create microflows table: %v", err) + } + + for _, row := range rows { + _, err := db.Exec(`INSERT INTO microflows VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row...) + if err != nil { + t.Fatalf("failed to insert row: %v", err) + } + // Ensure module exists + moduleName := row[3].(string) + if _, err := db.Exec(`INSERT OR IGNORE INTO modules (Name, Source) VALUES (?, '')`, moduleName); err != nil { + t.Fatalf("failed to insert module: %v", err) + } + } + + return db +} + +func TestEmptyMicroflowRule_NoViolations(t *testing.T) { + db := setupMicroflowsDB(t, [][]any{ + {"id1", "ACT_Process", "MyModule.ACT_Process", "MyModule", "", "Microflow", "", "Void", 0, 3, 1}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewEmptyMicroflowRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d", len(violations)) + } +} + +func TestEmptyMicroflowRule_DetectsEmpty(t *testing.T) { + db := setupMicroflowsDB(t, [][]any{ + {"id1", "ACT_Process", "MyModule.ACT_Process", "MyModule", "", "Microflow", "", "Void", 0, 0, 0}, + {"id2", "ACT_Other", "MyModule.ACT_Other", "MyModule", "", "Microflow", "", "Void", 0, 5, 2}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewEmptyMicroflowRule() + violations := rule.Check(ctx) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "MPR002" { + t.Errorf("expected rule ID MPR002, got %s", violations[0].RuleID) + } + if violations[0].Location.DocumentName != "ACT_Process" { + t.Errorf("expected document ACT_Process, got %s", violations[0].Location.DocumentName) + } +} + +func TestEmptyMicroflowRule_Metadata(t *testing.T) { + r := NewEmptyMicroflowRule() + if r.ID() != "MPR002" { + t.Errorf("ID = %q, want MPR002", r.ID()) + } + if r.Category() != "quality" { + t.Errorf("Category = %q, want quality", r.Category()) + } +} diff --git a/mdl/linter/rules/helpers_test.go b/mdl/linter/rules/helpers_test.go new file mode 100644 index 00000000..6ec0b45c --- /dev/null +++ b/mdl/linter/rules/helpers_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "database/sql" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + + _ "modernc.org/sqlite" +) + +// testMicroflow returns a synthetic Microflow for use in walker unit tests. +func testMicroflow() linter.Microflow { + return linter.Microflow{ + ID: "mf1", + Name: "ACT_Process", + QualifiedName: "MyModule.ACT_Process", + ModuleName: "MyModule", + } +} + +// setupEntitiesDB creates an in-memory SQLite database with entities and modules tables. +func setupEntitiesDB(t *testing.T, entities [][]any) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open in-memory db: %v", err) + } + + _, err = db.Exec(`CREATE TABLE modules (Name TEXT PRIMARY KEY, Source TEXT)`) + if err != nil { + t.Fatalf("failed to create modules table: %v", err) + } + + _, err = db.Exec(`CREATE TABLE entities ( + Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + EntityType TEXT, Description TEXT, Generalization TEXT, + AttributeCount INTEGER, AccessRuleCount INTEGER, ValidationRuleCount INTEGER, + HasEventHandlers INTEGER, IsExternal INTEGER + )`) + if err != nil { + t.Fatalf("failed to create entities table: %v", err) + } + + for _, row := range entities { + _, err := db.Exec(`INSERT INTO entities VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + row...) + if err != nil { + t.Fatalf("failed to insert entity: %v", err) + } + moduleName := row[3].(string) + if _, err := db.Exec(`INSERT OR IGNORE INTO modules (Name, Source) VALUES (?, '')`, moduleName); err != nil { + t.Fatalf("failed to insert module: %v", err) + } + } + + return db +} diff --git a/mdl/linter/rules/mpr008_overlapping_activities_test.go b/mdl/linter/rules/mpr008_overlapping_activities_test.go new file mode 100644 index 00000000..3964e89d --- /dev/null +++ b/mdl/linter/rules/mpr008_overlapping_activities_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// NOTE: The full Check() logic requires ctx.Reader().GetMicroflow() to read microflow +// positions from a real MPR file. The overlap detection algorithm (collect positions → +// pairwise distance check) is inline in Check() and cannot be unit-tested without +// building a mock mpr.Reader. This rule currently lacks end-to-end coverage; +// behavioral testing requires a real .mpr project with overlapping activities. + +func TestOverlappingActivitiesRule_NilReader(t *testing.T) { + r := NewOverlappingActivitiesRule() + ctx := linter.NewLintContextFromDB(nil) + violations := r.Check(ctx) + if violations != nil { + t.Errorf("expected nil with nil reader, got %v", violations) + } +} + +func TestOverlappingActivitiesRule_Metadata(t *testing.T) { + r := NewOverlappingActivitiesRule() + if r.ID() != "MPR008" { + t.Errorf("ID = %q, want MPR008", r.ID()) + } + if r.Category() != "correctness" { + t.Errorf("Category = %q, want correctness", r.Category()) + } + if r.Name() != "OverlappingActivities" { + t.Errorf("Name = %q, want OverlappingActivities", r.Name()) + } +} + +// The Check() method walks microflow positions via ctx.Reader().GetMicroflow() and detects +// overlapping activities using pairwise distance checks against internal heuristic constants +// (activityBoxWidth, activityBoxHeight). Since the collect function is defined inline in +// Check(), behavioral testing requires a real *mpr.Reader with positioned activities. diff --git a/mdl/linter/rules/naming_test.go b/mdl/linter/rules/naming_test.go new file mode 100644 index 00000000..94d69df9 --- /dev/null +++ b/mdl/linter/rules/naming_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import "testing" + +func TestIsPascalCase(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"Customer", true}, + {"CustomerOrder", true}, + {"A", true}, + {"ABC123", true}, + {"customer", false}, + {"customerOrder", false}, + {"Customer_Order", false}, + {"customer-order", false}, + {"123Customer", false}, + {"", false}, + } + for _, tt := range tests { + got := IsPascalCase(tt.input) + if got != tt.want { + t.Errorf("IsPascalCase(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIsCamelCase(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"customer", true}, + {"customerOrder", true}, + {"a", true}, + {"abc123", true}, + {"Customer", false}, + {"customer_order", false}, + {"customer-order", false}, + {"123customer", false}, + {"", false}, + } + for _, tt := range tests { + got := IsCamelCase(tt.input) + if got != tt.want { + t.Errorf("IsCamelCase(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestSplitWords(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"customerOrder", []string{"customer", "Order"}}, + {"CustomerOrder", []string{"Customer", "Order"}}, + {"customer_order", []string{"customer", "order"}}, + {"customer-order", []string{"customer", "order"}}, + {"ABC", []string{"ABC"}}, + {"simple", []string{"simple"}}, + {"", nil}, + } + for _, tt := range tests { + got := splitWords(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitWords(%q) = %v, want %v", tt.input, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitWords(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} + +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"customer_order", "CustomerOrder"}, + {"customer-order", "CustomerOrder"}, + {"customerOrder", "CustomerOrder"}, + {"Customer", "Customer"}, + {"CUSTOMER", "Customer"}, + {"", ""}, + } + for _, tt := range tests { + got := toPascalCase(tt.input) + if got != tt.want { + t.Errorf("toPascalCase(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestSuggestMicroflowName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"ACT_do_something", "ACT_DoSomething"}, + {"SUB_helper", "SUB_Helper"}, + {"doSomething", "DoSomething"}, + {"ACT_Already", "ACT_Already"}, + } + for _, tt := range tests { + got := suggestMicroflowName(tt.input) + if got != tt.want { + t.Errorf("suggestMicroflowName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNamingConventionRule_Metadata(t *testing.T) { + r := NewNamingConventionRule() + if r.ID() != "MPR001" { + t.Errorf("ID = %q, want MPR001", r.ID()) + } + if r.Category() != "style" { + t.Errorf("Category = %q, want style", r.Category()) + } + if r.Name() != "NamingConvention" { + t.Errorf("Name = %q, want NamingConvention", r.Name()) + } +} + +func TestDefaultPatterns(t *testing.T) { + // Entity pattern + if !DefaultEntityPattern.MatchString("Customer") { + t.Error("entity pattern should match 'Customer'") + } + if DefaultEntityPattern.MatchString("customer") { + t.Error("entity pattern should not match 'customer'") + } + if DefaultEntityPattern.MatchString("Customer_Order") { + t.Error("entity pattern should not match 'Customer_Order'") + } + + // Microflow pattern + if !DefaultMicroflowPattern.MatchString("ACT_CreateCustomer") { + t.Error("microflow pattern should match 'ACT_CreateCustomer'") + } + if !DefaultMicroflowPattern.MatchString("ProcessOrder") { + t.Error("microflow pattern should match 'ProcessOrder'") + } + if DefaultMicroflowPattern.MatchString("processOrder") { + t.Error("microflow pattern should not match 'processOrder'") + } + + // Page pattern + if !DefaultPagePattern.MatchString("Customer_Edit") { + t.Error("page pattern should match 'Customer_Edit'") + } + if DefaultPagePattern.MatchString("customer_edit") { + t.Error("page pattern should not match 'customer_edit'") + } + + // Enumeration pattern + if !DefaultEnumerationPattern.MatchString("OrderStatus") { + t.Error("enumeration pattern should match 'OrderStatus'") + } + if DefaultEnumerationPattern.MatchString("orderStatus") { + t.Error("enumeration pattern should not match 'orderStatus'") + } +} diff --git a/mdl/linter/rules/page_navigation_security_test.go b/mdl/linter/rules/page_navigation_security_test.go new file mode 100644 index 00000000..271c9a96 --- /dev/null +++ b/mdl/linter/rules/page_navigation_security_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" +) + +func TestCollectMenuPages_Flat(t *testing.T) { + items := []*types.NavMenuItem{ + {Page: "MyModule.HomePage", Caption: "Home"}, + {Page: "MyModule.About", Caption: "About"}, + } + + navPages := make(map[string][]navUsage) + collectMenuPages(items, "Responsive", navPages) + + if len(navPages) != 2 { + t.Errorf("expected 2 pages, got %d", len(navPages)) + } + if usages, ok := navPages["MyModule.HomePage"]; !ok || len(usages) != 1 { + t.Errorf("expected 1 usage for HomePage") + } +} + +func TestCollectMenuPages_Nested(t *testing.T) { + items := []*types.NavMenuItem{ + { + Caption: "Parent", + Items: []*types.NavMenuItem{ + {Page: "MyModule.ChildPage", Caption: "Child"}, + }, + }, + } + + navPages := make(map[string][]navUsage) + collectMenuPages(items, "Responsive", navPages) + + if _, ok := navPages["MyModule.ChildPage"]; !ok { + t.Error("expected nested page to be collected") + } +} + +func TestCollectMenuPages_EmptyPage(t *testing.T) { + items := []*types.NavMenuItem{ + {Page: "", Caption: "Separator"}, + } + + navPages := make(map[string][]navUsage) + collectMenuPages(items, "Responsive", navPages) + + if len(navPages) != 0 { + t.Errorf("expected 0 pages for empty page ref, got %d", len(navPages)) + } +} + +func TestModuleFromQualified(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"MyModule.Page", "MyModule"}, + {"Admin.Login", "Admin"}, + {"NoModule", "NoModule"}, + {"", ""}, + } + for _, tt := range tests { + got := moduleFromQualified(tt.input) + if got != tt.want { + t.Errorf("moduleFromQualified(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestPageNavigationSecurityRule_NilReader(t *testing.T) { + r := NewPageNavigationSecurityRule() + ctx := linter.NewLintContextFromDB(nil) + violations := r.Check(ctx) + if violations != nil { + t.Errorf("expected nil with nil reader, got %v", violations) + } +} + +func TestPageNavigationSecurityRule_Metadata(t *testing.T) { + r := NewPageNavigationSecurityRule() + if r.ID() != "MPR007" { + t.Errorf("ID = %q, want MPR007", r.ID()) + } + if r.Category() != "security" { + t.Errorf("Category = %q, want security", r.Category()) + } +} diff --git a/mdl/linter/rules/security_test.go b/mdl/linter/rules/security_test.go new file mode 100644 index 00000000..fd18267b --- /dev/null +++ b/mdl/linter/rules/security_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +func TestNoEntityAccessRulesRule_NoViolation(t *testing.T) { + db := setupEntitiesDB(t, [][]any{ + {"id1", "Customer", "MyModule.Customer", "MyModule", "", "PERSISTENT", "", "", 5, 2, 0, 0, 0}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewNoEntityAccessRulesRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d", len(violations)) + } +} + +func TestNoEntityAccessRulesRule_DetectsMissing(t *testing.T) { + db := setupEntitiesDB(t, [][]any{ + {"id1", "Customer", "MyModule.Customer", "MyModule", "", "PERSISTENT", "", "", 5, 0, 0, 0, 0}, + {"id2", "Order", "MyModule.Order", "MyModule", "", "PERSISTENT", "", "", 3, 1, 0, 0, 0}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewNoEntityAccessRulesRule() + violations := rule.Check(ctx) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "SEC001" { + t.Errorf("expected rule ID SEC001, got %s", violations[0].RuleID) + } + if violations[0].Location.DocumentName != "Customer" { + t.Errorf("expected document Customer, got %s", violations[0].Location.DocumentName) + } +} + +func TestNoEntityAccessRulesRule_NonPersistentIgnored(t *testing.T) { + db := setupEntitiesDB(t, [][]any{ + {"id1", "TempObj", "MyModule.TempObj", "MyModule", "", "NON_PERSISTENT", "", "", 2, 0, 0, 0, 0}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewNoEntityAccessRulesRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for non-persistent entity, got %d", len(violations)) + } +} + +func TestNoEntityAccessRulesRule_ExternalIgnored(t *testing.T) { + db := setupEntitiesDB(t, [][]any{ + {"id1", "ExtEntity", "MyModule.ExtEntity", "MyModule", "", "PERSISTENT", "", "", 2, 0, 0, 0, 1}, + }) + defer db.Close() + + ctx := linter.NewLintContextFromDB(db) + rule := NewNoEntityAccessRulesRule() + violations := rule.Check(ctx) + + if len(violations) != 0 { + t.Errorf("expected 0 violations for external entity, got %d", len(violations)) + } +} + +func TestNoEntityAccessRulesRule_Metadata(t *testing.T) { + r := NewNoEntityAccessRulesRule() + if r.ID() != "SEC001" { + t.Errorf("ID = %q, want SEC001", r.ID()) + } + if r.Category() != "security" { + t.Errorf("Category = %q, want security", r.Category()) + } +} + +// NOTE: SEC002 and SEC003 require ctx.Reader() → *mpr.Reader to call GetProjectSecurity(). +// Without a real MPR file, we can only test the nil-reader early return and metadata. +// Full behavioral coverage requires integration tests with a real .mpr project. + +func TestWeakPasswordPolicyRule_NilReader(t *testing.T) { + ctx := linter.NewLintContextFromDB(nil) + rule := NewWeakPasswordPolicyRule() + violations := rule.Check(ctx) + + if violations != nil { + t.Errorf("expected nil with nil reader, got %v", violations) + } +} + +// SEC003: Demo users still active in production +// Without a real MPR file, we can only test the nil-reader early return and metadata. + +func TestDemoUsersActiveRule_NilReader(t *testing.T) { + r := NewDemoUsersActiveRule() + ctx := linter.NewLintContextFromDB(nil) + violations := r.Check(ctx) + if violations != nil { + t.Errorf("expected nil with nil reader, got %v", violations) + } +} + +func TestDemoUsersActiveRule_Metadata(t *testing.T) { + r := NewDemoUsersActiveRule() + if r.ID() != "SEC003" { + t.Errorf("ID = %q, want SEC003", r.ID()) + } + if r.Category() != "security" { + t.Errorf("Category = %q, want security", r.Category()) + } +} diff --git a/mdl/linter/rules/validation_feedback_test.go b/mdl/linter/rules/validation_feedback_test.go new file mode 100644 index 00000000..ca2571f0 --- /dev/null +++ b/mdl/linter/rules/validation_feedback_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestIsEmptyTemplate_Nil(t *testing.T) { + vf := µflows.ValidationFeedbackAction{Template: nil} + if !isEmptyTemplate(vf) { + t.Error("expected true for nil template") + } +} + +func TestIsEmptyTemplate_EmptyTranslations(t *testing.T) { + vf := µflows.ValidationFeedbackAction{ + Template: &model.Text{Translations: map[string]string{}}, + } + if !isEmptyTemplate(vf) { + t.Error("expected true for empty translations") + } +} + +func TestIsEmptyTemplate_AllEmpty(t *testing.T) { + vf := µflows.ValidationFeedbackAction{ + Template: &model.Text{Translations: map[string]string{"en_US": "", "nl_NL": ""}}, + } + if !isEmptyTemplate(vf) { + t.Error("expected true when all translations empty") + } +} + +func TestIsEmptyTemplate_HasContent(t *testing.T) { + vf := µflows.ValidationFeedbackAction{ + Template: &model.Text{Translations: map[string]string{"en_US": "Please fill in this field"}}, + } + if isEmptyTemplate(vf) { + t.Error("expected false when translation has content") + } +} + +func TestWalkObjects_EmptyValidation(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: µflows.ValidationFeedbackAction{ + Template: nil, + }, + }, + } + + var violations []linter.Violation + r := NewValidationFeedbackRule() + walkObjects(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].RuleID != "MPR004" { + t.Errorf("expected MPR004, got %s", violations[0].RuleID) + } +} + +func TestWalkObjects_ValidFeedback(t *testing.T) { + objects := []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: µflows.ValidationFeedbackAction{ + Template: &model.Text{Translations: map[string]string{"en_US": "Required"}}, + }, + }, + } + + var violations []linter.Violation + r := NewValidationFeedbackRule() + walkObjects(objects, testMicroflow(), r, &violations) + + if len(violations) != 0 { + t.Errorf("expected 0 violations, got %d", len(violations)) + } +} + +func TestWalkObjects_InsideLoop(t *testing.T) { + loopBody := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{}, + Action: µflows.ValidationFeedbackAction{ + Template: nil, + }, + }, + }, + } + objects := []microflows.MicroflowObject{ + µflows.LoopedActivity{ + ObjectCollection: loopBody, + }, + } + + var violations []linter.Violation + r := NewValidationFeedbackRule() + walkObjects(objects, testMicroflow(), r, &violations) + + if len(violations) != 1 { + t.Errorf("expected 1 violation inside loop, got %d", len(violations)) + } +} + +func TestValidationFeedbackRule_Metadata(t *testing.T) { + r := NewValidationFeedbackRule() + if r.ID() != "MPR004" { + t.Errorf("ID = %q, want MPR004", r.ID()) + } + if r.Category() != "correctness" { + t.Errorf("Category = %q, want correctness", r.Category()) + } +}