From a7d9fdbc16888554ec44dc192222aff0ef9037c9 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 13 Sep 2026 15:49:27 +0200 Subject: [PATCH 1/5] feat(TA0002): report actions invoked on a directly opened part test page Reports Invoke(), Enabled() and Visible() on an action of a TestPage variable whose target page has PageType = ListPart or CardPart. A part page opened directly renders no actions at runtime. The working pattern is to reach the action through the part control of the hosting page. Not reported: actions through a part control, field access and TestPage built-ins, built-in OK/Cancel/Yes/No/View/Edit actions, HeadlinePart, TestRequestPage, obsolete test code. EnumProvider: NavTypeKind.TestPage, PageTypeKind.CardPart. Co-Authored-By: Claude Fable 5.1 --- src/ALCops.Common/Reflection/EnumProvider.cs | 6 ++ .../HasDiagnostic/CardPartLocalVariable.al | 37 ++++++++++++ .../ListPartActionFromPageExtension.al | 40 +++++++++++++ .../ListPartEnabledAndVisible.al | 40 +++++++++++++ .../HasDiagnostic/ListPartGlobalVariable.al | 38 +++++++++++++ .../ListPartInvokeWithoutParentheses.al | 37 ++++++++++++ .../HasDiagnostic/ListPartLocalVariable.al | 37 ++++++++++++ .../HasDiagnostic/ListPartVarParameter.al | 42 ++++++++++++++ .../HasDiagnostic/NamespacedListPart.al | 39 +++++++++++++ .../HasDiagnostic/ObsoletePartPage.al | 39 +++++++++++++ .../InvokeActionOnPartTestPage.cs | 57 +++++++++++++++++++ .../NoDiagnostic/ActionThroughPartControl.al | 51 +++++++++++++++++ .../NoDiagnostic/BuiltInOkOnPart.al | 27 +++++++++ .../NoDiagnostic/CardPage.al | 37 ++++++++++++ .../NoDiagnostic/FieldAccessOnPart.al | 36 ++++++++++++ .../NoDiagnostic/HeadlinePart.al | 43 ++++++++++++++ .../NoDiagnostic/ListPage.al | 37 ++++++++++++ .../NoDiagnostic/ObsoleteTestMethod.al | 38 +++++++++++++ .../NoDiagnostic/OpenViewOnPart.al | 26 +++++++++ .../PageWithoutPageTypeProperty.al | 36 ++++++++++++ .../NoDiagnostic/TestRequestPage.al | 33 +++++++++++ .../ALCops.TestAutomationCopAnalyzers.resx | 12 ++++ .../Analyzers/InvokeActionOnPartTestPage.cs | 54 ++++++++++++++++++ .../DiagnosticDescriptors.cs | 10 ++++ src/ALCops.TestAutomationCop/DiagnosticIds.cs | 1 + 25 files changed, 853 insertions(+) create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/CardPartLocalVariable.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartActionFromPageExtension.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartEnabledAndVisible.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartGlobalVariable.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartInvokeWithoutParentheses.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartLocalVariable.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartVarParameter.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/NamespacedListPart.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoletePartPage.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ActionThroughPartControl.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/BuiltInOkOnPart.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/CardPage.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/FieldAccessOnPart.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/HeadlinePart.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ListPage.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/OpenViewOnPart.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/PageWithoutPageTypeProperty.al create mode 100644 src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/TestRequestPage.al create mode 100644 src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 8b1d06d9..7389f234 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -435,6 +435,8 @@ public static class NavTypeKind new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.Text))); private static readonly Lazy _variant = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.Variant))); + private static readonly Lazy _testPage = + new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.TestPage))); private static readonly Lazy _xmlPort = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.XmlPort))); @@ -481,6 +483,7 @@ public static class NavTypeKind public static NavCodeAnalysis.NavTypeKind TableExtension => _tableExtension.Value; public static NavCodeAnalysis.NavTypeKind TableFilter => _tableFilter.Value; public static NavCodeAnalysis.NavTypeKind Text => _text.Value; + public static NavCodeAnalysis.NavTypeKind TestPage => _testPage.Value; public static NavCodeAnalysis.NavTypeKind Variant => _variant.Value; public static NavCodeAnalysis.NavTypeKind XmlPort => _xmlPort.Value; } @@ -574,6 +577,8 @@ public static class PageTypeKind new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.API))); private static readonly Lazy _card = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Card))); + private static readonly Lazy _cardPart = + new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.CardPart))); private static readonly Lazy _document = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Document))); private static readonly Lazy _headlinePart = @@ -589,6 +594,7 @@ public static class PageTypeKind public static NavCodeAnalysis.PageTypeKind API => _api.Value; public static NavCodeAnalysis.PageTypeKind Card => _card.Value; + public static NavCodeAnalysis.PageTypeKind CardPart => _cardPart.Value; public static NavCodeAnalysis.PageTypeKind Document => _document.Value; public static NavCodeAnalysis.PageTypeKind HeadlinePart => _headlinePart.Value; public static NavCodeAnalysis.PageTypeKind List => _list.Value; diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/CardPartLocalVariable.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/CardPartLocalVariable.al new file mode 100644 index 00000000..ab2b3c1d --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/CardPartLocalVariable.al @@ -0,0 +1,37 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyCardPart +{ + PageType = CardPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyCardPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartActionFromPageExtension.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartActionFromPageExtension.al new file mode 100644 index 00000000..44311823 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartActionFromPageExtension.al @@ -0,0 +1,40 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; +} + +pageextension 50100 MyListPartExt extends MyListPart +{ + actions + { + addlast(processing) + { + action(ExtAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.ExtAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartEnabledAndVisible.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartEnabledAndVisible.al new file mode 100644 index 00000000..ce036bd5 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartEnabledAndVisible.al @@ -0,0 +1,40 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + IsEnabled: Boolean; + IsVisible: Boolean; + begin + SubPage.OpenView(); + IsEnabled := [|SubPage.MyAction.Enabled()|]; + IsVisible := [|SubPage.MyAction.Visible()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartGlobalVariable.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartGlobalVariable.al new file mode 100644 index 00000000..3d9cac3a --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartGlobalVariable.al @@ -0,0 +1,38 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + var + SubPage: TestPage MyListPart; + + [Test] + procedure MyTest() + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartInvokeWithoutParentheses.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartInvokeWithoutParentheses.al new file mode 100644 index 00000000..0f5312e9 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartInvokeWithoutParentheses.al @@ -0,0 +1,37 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartLocalVariable.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartLocalVariable.al new file mode 100644 index 00000000..eaa88595 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartLocalVariable.al @@ -0,0 +1,37 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartVarParameter.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartVarParameter.al new file mode 100644 index 00000000..a150ee13 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ListPartVarParameter.al @@ -0,0 +1,42 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + InvokeOnPart(SubPage); + end; + + local procedure InvokeOnPart(var PartPage: TestPage MyListPart) + begin + [|PartPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/NamespacedListPart.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/NamespacedListPart.al new file mode 100644 index 00000000..785728dd --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/NamespacedListPart.al @@ -0,0 +1,39 @@ +namespace MyPublisher.MyExtension.MyAppDomain; + +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyPublisher.MyExtension.MyAppDomain.MyListPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoletePartPage.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoletePartPage.al new file mode 100644 index 00000000..c14dcdf4 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoletePartPage.al @@ -0,0 +1,39 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by a new page.'; + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs new file mode 100644 index 00000000..6153d2d1 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs @@ -0,0 +1,57 @@ +using RoslynTestKit; + +namespace ALCops.TestAutomationCop.Test; + +public class InvokeActionOnPartTestPage : NavCodeAnalysisBase +{ + private AnalyzerTestFixture _fixture; + private string _testCasePath; + + [SetUp] + public void Setup() + { + _fixture = RoslynFixtureFactory.Create(); + + _testCasePath = Path.Combine( + Directory.GetParent( + Environment.CurrentDirectory)!.Parent!.Parent!.FullName, + Path.Combine("Rules", nameof(InvokeActionOnPartTestPage))); + } + + [Test] + [TestCase("ListPartLocalVariable")] + [TestCase("CardPartLocalVariable")] + [TestCase("ListPartGlobalVariable")] + [TestCase("ListPartVarParameter")] + [TestCase("ListPartActionFromPageExtension")] + [TestCase("ListPartEnabledAndVisible")] + [TestCase("ListPartInvokeWithoutParentheses")] + [TestCase("ObsoletePartPage")] + [TestCase("NamespacedListPart")] + public async Task HasDiagnostic(string testCase) + { + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) + .ConfigureAwait(false); + + _fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.InvokeActionOnPartTestPage); + } + + [Test] + [TestCase("ListPage")] + [TestCase("CardPage")] + [TestCase("PageWithoutPageTypeProperty")] + [TestCase("HeadlinePart")] + [TestCase("ActionThroughPartControl")] + [TestCase("FieldAccessOnPart")] + [TestCase("OpenViewOnPart")] + [TestCase("BuiltInOkOnPart")] + [TestCase("TestRequestPage")] + [TestCase("ObsoleteTestMethod")] + public async Task NoDiagnostic(string testCase) + { + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) + .ConfigureAwait(false); + + _fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.InvokeActionOnPartTestPage); + } +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ActionThroughPartControl.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ActionThroughPartControl.al new file mode 100644 index 00000000..c955718f --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ActionThroughPartControl.al @@ -0,0 +1,51 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +page 50101 MyHostPage +{ + PageType = Card; + SourceTable = MyTable; + + layout + { + area(content) + { + part(SubPagePart; MyListPart) { } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + MainPage: TestPage MyHostPage; + begin + MainPage.OpenView(); + [|MainPage.SubPagePart.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/BuiltInOkOnPart.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/BuiltInOkOnPart.al new file mode 100644 index 00000000..a47579b9 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/BuiltInOkOnPart.al @@ -0,0 +1,27 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.OK().Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/CardPage.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/CardPage.al new file mode 100644 index 00000000..d7731e0e --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/CardPage.al @@ -0,0 +1,37 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyCardPage +{ + PageType = Card; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + MyPage: TestPage MyCardPage; + begin + MyPage.OpenView(); + [|MyPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/FieldAccessOnPart.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/FieldAccessOnPart.al new file mode 100644 index 00000000..f073015c --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/FieldAccessOnPart.al @@ -0,0 +1,36 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + layout + { + area(content) + { + field(MyField; Rec.MyField) { } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + FieldValue: Integer; + begin + SubPage.OpenView(); + [|FieldValue := SubPage.MyField.AsInteger()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/HeadlinePart.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/HeadlinePart.al new file mode 100644 index 00000000..9a8d5eba --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/HeadlinePart.al @@ -0,0 +1,43 @@ +page 50100 MyHeadlinePart +{ + PageType = HeadlinePart; + + layout + { + area(content) + { + group(Control1) + { + ShowCaption = false; + + field(Headline; 'Hello') + { + } + } + } + } + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + MyPage: TestPage MyHeadlinePart; + begin + MyPage.OpenView(); + [|MyPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ListPage.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ListPage.al new file mode 100644 index 00000000..00a3d755 --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ListPage.al @@ -0,0 +1,37 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPage +{ + PageType = List; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + MyPage: TestPage MyListPage; + begin + MyPage.OpenView(); + [|MyPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al new file mode 100644 index 00000000..c64121ab --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al @@ -0,0 +1,38 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + [Obsolete('Replaced by a new test.', '1.0.0.0')] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + SubPage.OpenView(); + [|SubPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/OpenViewOnPart.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/OpenViewOnPart.al new file mode 100644 index 00000000..67a66bba --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/OpenViewOnPart.al @@ -0,0 +1,26 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyListPart +{ + PageType = ListPart; + SourceTable = MyTable; +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + SubPage: TestPage MyListPart; + begin + [|SubPage.OpenView()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/PageWithoutPageTypeProperty.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/PageWithoutPageTypeProperty.al new file mode 100644 index 00000000..dc5745cc --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/PageWithoutPageTypeProperty.al @@ -0,0 +1,36 @@ +table 50100 MyTable +{ + fields + { + field(1; MyField; Integer) { } + } +} + +page 50100 MyPage +{ + SourceTable = MyTable; + + actions + { + area(processing) + { + action(MyAction) + { + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + procedure MyTest() + var + MyPage: TestPage MyPage; + begin + MyPage.OpenView(); + [|MyPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/TestRequestPage.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/TestRequestPage.al new file mode 100644 index 00000000..40c6b8dc --- /dev/null +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/TestRequestPage.al @@ -0,0 +1,33 @@ +report 50100 MyReport +{ + requestpage + { + actions + { + area(processing) + { + action(MyAction) + { + } + } + } + } +} + +codeunit 50100 MyTestCodeunit +{ + Subtype = Test; + + [Test] + [HandlerFunctions('MyReportRequestPageHandler')] + procedure MyTest() + begin + Report.Run(Report::MyReport); + end; + + [RequestPageHandler] + procedure MyReportRequestPageHandler(var RequestPage: TestRequestPage MyReport) + begin + [|RequestPage.MyAction.Invoke()|]; + end; +} diff --git a/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx b/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx index 841b6807..0a87543d 100644 --- a/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx +++ b/src/ALCops.TestAutomationCop/ALCops.TestAutomationCopAnalyzers.resx @@ -129,6 +129,18 @@ Codeunits with Subtype = Test define the executable surface for automated tests and must contain only explicit test entry points. Any global procedure declared in a test codeunit is implicitly exposed as callable logic and therefore must represent an actual test, explicitly marked with the [Test] attribute. A global procedure without this attribute indicates either an incomplete test or misplaced reusable logic. + + Actions cannot be invoked on a part page opened directly through its own TestPage variable + + + + Action '{0}' cannot be invoked on page '{1}' opened directly because its PageType is {2}; invoke it through the part control of the hosting page instead. + + + + A ListPart or CardPart page opened directly through its own TestPage variable renders no actions at runtime. Invoking, querying Enabled or querying Visible on any action will fail with 'The action with ID = xxx is not found on the page.' To reach the action, open the hosting page and invoke it through the part control (e.g. HostPage.SubPagePart.MyAction.Invoke()). + + Analyzer threw an unhandled exception diff --git a/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs b/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs new file mode 100644 index 00000000..490c4c0d --- /dev/null +++ b/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using ALCops.Common.Extensions; +using ALCops.Common.Reflection; +using Microsoft.Dynamics.Nav.CodeAnalysis; +using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; +using Microsoft.Dynamics.Nav.CodeAnalysis.Symbols; + +namespace ALCops.TestAutomationCop.Analyzers; + +[DiagnosticAnalyzer] +public sealed class InvokeActionOnPartTestPage : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DiagnosticDescriptors.InvokeActionOnPartTestPage); + + public override void Initialize(AnalysisContext context) + { + context.RegisterOperationAction( + AnalyzeInvocation, + EnumProvider.OperationKind.InvocationExpression); + } + + private static void AnalyzeInvocation(OperationAnalysisContext ctx) + { + if (ctx.IsObsolete() || ctx.Operation is not IInvocationExpression invocation) + return; + + var method = invocation.TargetMethod; + if (method.MethodKind != EnumProvider.MethodKind.BuiltInMethod || + method.ContainingSymbol is not IClassTypeSymbol cls || + !SemanticFacts.IsSameName(cls.Name, "TestAction")) + return; + + // MainPage.Part.Action binds its receiver as a TestPart; only a TestPage receiver is a part opened directly. + if (invocation.Instance is not ITestActionAccess access) + return; + + var receiverType = access.Instance?.Type; + if (receiverType is null || receiverType.GetNavTypeKindSafe() != EnumProvider.NavTypeKind.TestPage) + return; + + if (receiverType.OriginalDefinition is not IPageTypeSymbol page) + return; + + var pageType = page.PageType; + if (pageType != EnumProvider.PageTypeKind.ListPart && pageType != EnumProvider.PageTypeKind.CardPart) + return; + + ctx.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvokeActionOnPartTestPage, + invocation.Syntax.GetLocation(), + access.ActionSymbol.Name, page.Name, pageType.ToString())); + } +} diff --git a/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs b/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs index 29f70b44..5b86d909 100644 --- a/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs +++ b/src/ALCops.TestAutomationCop/DiagnosticDescriptors.cs @@ -15,6 +15,16 @@ public static class DiagnosticDescriptors description: TestAutomationCopAnalyzers.GlobalMethodRequiresTestAttributeDescription, helpLinkUri: GetHelpUri(DiagnosticIds.GlobalMethodRequiresTestAttribute)); + public static readonly DiagnosticDescriptor InvokeActionOnPartTestPage = new( + id: DiagnosticIds.InvokeActionOnPartTestPage, + title: TestAutomationCopAnalyzers.InvokeActionOnPartTestPageTitle, + messageFormat: TestAutomationCopAnalyzers.InvokeActionOnPartTestPageMessageFormat, + category: Category.Usage, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: TestAutomationCopAnalyzers.InvokeActionOnPartTestPageDescription, + helpLinkUri: GetHelpUri(DiagnosticIds.InvokeActionOnPartTestPage)); + public static readonly DiagnosticDescriptor AnalyzerException = new( id: DiagnosticIds.AnalyzerException, title: TestAutomationCopAnalyzers.AnalyzerExceptionTitle, diff --git a/src/ALCops.TestAutomationCop/DiagnosticIds.cs b/src/ALCops.TestAutomationCop/DiagnosticIds.cs index 941b50c4..23cfb46b 100644 --- a/src/ALCops.TestAutomationCop/DiagnosticIds.cs +++ b/src/ALCops.TestAutomationCop/DiagnosticIds.cs @@ -4,4 +4,5 @@ public static class DiagnosticIds { public static readonly string AnalyzerException = "TA0000"; public static readonly string GlobalMethodRequiresTestAttribute = "TA0001"; + public static readonly string InvokeActionOnPartTestPage = "TA0002"; } \ No newline at end of file From db15f3ff55c558ae5bfddc5446ed4a486151ea5d Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 13 Sep 2026 15:51:37 +0200 Subject: [PATCH 2/5] docs(TA0002): add rule doc Purpose, design decisions, deliberate non-reports and the SDK facts (TestPage receiver type, TestActionAccess versus TestPartAccess, the TestAction built-in class) that the analyzer relies on. Co-Authored-By: Claude Fable 5.1 --- .../ta0002-invoke-action-on-part-test-page.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md diff --git a/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md new file mode 100644 index 00000000..84a6d7ac --- /dev/null +++ b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md @@ -0,0 +1,49 @@ +--- +paths: + - "src/ALCops.TestAutomationCop/**/InvokeActionOnPartTestPage*" + - "src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/**" +--- + +# TA0002: InvokeActionOnPartTestPage + +## Purpose + +Reports `Invoke()`, `Enabled()` and `Visible()` calls on an action of a `TestPage` variable whose target page has `PageType = ListPart` or `CardPart`. A part page opened directly through its own `TestPage` renders no actions at runtime, so the test fails with "The action with ID = xxx is not found on the page." The working pattern is to invoke the action through the part control of the hosting page (`HostPage.SubPagePart.MyAction.Invoke()`). + +Registers `RegisterOperationAction` on `InvocationExpression`; main type `InvokeActionOnPartTestPage`. + +**References:** https://github.com/ALCops/Analyzers/discussions/455 + +## Design decisions + +| Decision | Rationale | +|---|---| +| Register on `InvocationExpression` rather than a hypothetical `TestActionAccess` operation kind | `IOperation` has no `Parent`, so the action access alone cannot reach the containing `Invoke`/`Enabled`/`Visible` call; the invocation has both the target method identity and the `ITestActionAccess` instance. Covers both `Invoke()` and `Invoke` (without parentheses) because `IInvocationExpression` represents both spellings. | +| All three `TestAction` methods (`Invoke`, `Enabled`, `Visible`) | They are the only members of the built-in `TestAction` class; all three fail identically at runtime on a directly opened part page. | +| Type-based reach (every body, no `Subtype = Test` gate) | `TestPage` variables can appear in helper codeunits that are not `Subtype = Test`; gating on subtype would miss those. | +| Only `ListPart` and `CardPart` page types | `HeadlinePart` is not confirmed to fail the same way; silence is safer. A page without an explicit `PageType` property defaults to `Card`, which is not a part type and is therefore silent. | +| Anchor on the `TestAction` built-in class symbol, not on the method name | A name-only check could match a future built-in on another class; the class identity is stable. | +| Obsolete part page is still reported | The runtime failure is real even when the page is `ObsoleteState = Pending`; only obsolete test code (`ctx.IsObsolete()`) is skipped. | +| Severity `Warning`, category `Usage` | The call always fails at runtime, but Warning (not Error) matches the convention for rules that do not prevent compilation. | +| No CodeFix | The fix requires knowing the hosting page and its part control name; no mechanical rewrite exists. | +| No `PageTypeKind` sentinel hardening | A missing `PageTypeKind` member would only cause silence (the equality check fails); the sentinel pattern is unnecessary here. | + +## Deliberate non-reports + +- Actions reached through a part control (`MainPage.SubPagePart.MyAction.Invoke()`): the `ITestActionAccess.Instance` is typed `TestPart` (`NavTypeKind.TestPart`), not `TestPage`, so the `NavTypeKind` check excludes it. +- Field access, `OpenView`/`OpenEdit`/`OpenNew` and all other `TestPage` built-ins on a part: these are not `TestAction` members and pass through the `ContainingSymbol` class-name check. +- Built-in actions `OK`/`Cancel`/`Yes`/`No`/`View`/`Edit`: `SubPage.OK().Invoke()` has an invocation (the `OK()` call) as the `Instance` of the outer `Invoke`, not an `ITestActionAccess`. +- `HeadlinePart` and every other `PageType` not confirmed to fail. +- `TestRequestPage`: its receiver type is `RequestPageTypeSymbol`, which is `IPageBaseTypeSymbol` but not `IPageTypeSymbol`; the `OriginalDefinition is not IPageTypeSymbol` cast bails out. +- Obsolete test code: `ctx.IsObsolete()` returns true when the enclosing method or object is obsolete, skipping the diagnostic. + +## SDK facts + +- `TestPage "X"` type is the internal `TestPageTypeSymbol`: `NavTypeKind.TestPage`, `OriginalDefinition` is the `PageTypeSymbol` (public `IPageTypeSymbol`, `PageType` on `IPageBaseTypeSymbol`). No public `ITestPageTypeSymbol`. +- `X.SomeAction` binds to `BoundTestActionAccess : ITestActionAccess` (`Instance`, `ActionSymbol : IActionSymbol`). +- `MainPage.Part` binds to `BoundTestPartAccess : ITestPartAccess`; its `Type` is `TestPartSymbol` (`NavTypeKind.TestPart`). For `MainPage.Part.Action.Invoke()` the `ITestActionAccess.Instance` is typed `TestPart`; for `SubPage.Action.Invoke()` it is typed `TestPage`. +- `.Invoke()`/`.Enabled()`/`.Visible()` are the only three members of the built-in class `TestAction` (`Symbols/TestActionClassTypeSymbol.cs:7-12`); the call is an `IInvocationExpression` with `TargetMethod.MethodKind == BuiltInMethod`, `ContainingSymbol` the `IClassTypeSymbol` named `TestAction`. +- `OK`/`Cancel`/`Yes`/`No`/`View`/`Edit` are `TestPage` built-ins returning `TestActionType`: `SubPage.OK().Invoke()` has an invocation as `Instance`, never an `ITestActionAccess`. +- Pageextension actions are folded into the test page members; classify by receiver type, not by `IActionSymbol.ContainingSymbol`. +- `TestRequestPage` is `RequestPageTypeSymbol`, which is `IPageBaseTypeSymbol` but not `IPageTypeSymbol`, so the cast bails out silently. +- Bare `SubPage.SomeAction;` does not compile, so every `TestAction` access is one of the three calls. From b624df0fc2757a0ab617f6d7525378da68ec5fe2 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 13 Sep 2026 16:14:24 +0200 Subject: [PATCH 3/5] test(TA0002): skip the same-module pageextension fixture below runtime 13.0 AL 12 rejects a pageextension whose target page is declared in the same module (AL0334), so the fixture cannot compile on those SDK versions. Co-Authored-By: Claude Fable 5.1 --- .../diagnostics/ta0002-invoke-action-on-part-test-page.md | 4 ++++ .../InvokeActionOnPartTestPage.cs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md index 84a6d7ac..bd8d3dc4 100644 --- a/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md +++ b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md @@ -37,6 +37,10 @@ Registers `RegisterOperationAction` on `InvocationExpression`; main type `Invoke - `TestRequestPage`: its receiver type is `RequestPageTypeSymbol`, which is `IPageBaseTypeSymbol` but not `IPageTypeSymbol`; the `OriginalDefinition is not IPageTypeSymbol` cast bails out. - Obsolete test code: `ctx.IsObsolete()` returns true when the enclosing method or object is obsolete, skipping the diagnostic. +## Test notes + +- `ListPartActionFromPageExtension` is skipped below runtime 13.0: AL 12 rejects a pageextension whose target page is declared in the same module (AL0334). + ## SDK facts - `TestPage "X"` type is the internal `TestPageTypeSymbol`: `NavTypeKind.TestPage`, `OriginalDefinition` is the `PageTypeSymbol` (public `IPageTypeSymbol`, `PageType` on `IPageBaseTypeSymbol`). No public `ITestPageTypeSymbol`. diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs index 6153d2d1..4c07217a 100644 --- a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs @@ -30,6 +30,12 @@ public void Setup() [TestCase("NamespacedListPart")] public async Task HasDiagnostic(string testCase) { + SkipTestIfVersionIsTooLow( + ["ListPartActionFromPageExtension"], + testCase, + "13.0", + "No support for pageextensions when target itself is already declared in the same module"); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(HasDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); From 5171aa6047ce1c99b4fc686cce49bf7d0f1844b5 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 13 Sep 2026 16:20:57 +0200 Subject: [PATCH 4/5] chore(TA0002): keep EnumProvider NavTypeKind members in alphabetical order Co-Authored-By: Claude Fable 5.1 --- src/ALCops.Common/Reflection/EnumProvider.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index 7389f234..24734c11 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -431,12 +431,12 @@ public static class NavTypeKind new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.TableExtension))); private static readonly Lazy _tableFilter = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.TableFilter))); + private static readonly Lazy _testPage = + new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.TestPage))); private static readonly Lazy _text = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.Text))); private static readonly Lazy _variant = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.Variant))); - private static readonly Lazy _testPage = - new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.TestPage))); private static readonly Lazy _xmlPort = new(() => ParseEnum(nameof(NavCodeAnalysis.NavTypeKind.XmlPort))); @@ -482,8 +482,8 @@ public static class NavTypeKind public static NavCodeAnalysis.NavTypeKind String => _string.Value; public static NavCodeAnalysis.NavTypeKind TableExtension => _tableExtension.Value; public static NavCodeAnalysis.NavTypeKind TableFilter => _tableFilter.Value; - public static NavCodeAnalysis.NavTypeKind Text => _text.Value; public static NavCodeAnalysis.NavTypeKind TestPage => _testPage.Value; + public static NavCodeAnalysis.NavTypeKind Text => _text.Value; public static NavCodeAnalysis.NavTypeKind Variant => _variant.Value; public static NavCodeAnalysis.NavTypeKind XmlPort => _xmlPort.Value; } From e629e3f65c22cb0f95a22d2874cb70c5425d52d3 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 13 Sep 2026 16:46:52 +0200 Subject: [PATCH 5/5] fix(TA0002): report obsolete test code and identify the call by its action access alone The test runner still executes an obsolete test method or codeunit, so skipping obsolete code hid a real runtime failure. The built-in class and method-kind checks were implied by the ITestActionAccess receiver: its type is TestAction, whose only members are Invoke, Enabled and Visible. Co-Authored-By: Claude Fable 5.1 --- .../ta0002-invoke-action-on-part-test-page.md | 5 ++--- .../ObsoleteTestMethod.al | 0 .../InvokeActionOnPartTestPage.cs | 2 +- .../Analyzers/InvokeActionOnPartTestPage.cs | 13 +++++-------- 4 files changed, 8 insertions(+), 12 deletions(-) rename src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/{NoDiagnostic => HasDiagnostic}/ObsoleteTestMethod.al (100%) diff --git a/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md index bd8d3dc4..83815aee 100644 --- a/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md +++ b/.claude/rules/diagnostics/ta0002-invoke-action-on-part-test-page.md @@ -22,8 +22,8 @@ Registers `RegisterOperationAction` on `InvocationExpression`; main type `Invoke | All three `TestAction` methods (`Invoke`, `Enabled`, `Visible`) | They are the only members of the built-in `TestAction` class; all three fail identically at runtime on a directly opened part page. | | Type-based reach (every body, no `Subtype = Test` gate) | `TestPage` variables can appear in helper codeunits that are not `Subtype = Test`; gating on subtype would miss those. | | Only `ListPart` and `CardPart` page types | `HeadlinePart` is not confirmed to fail the same way; silence is safer. A page without an explicit `PageType` property defaults to `Card`, which is not a part type and is therefore silent. | -| Anchor on the `TestAction` built-in class symbol, not on the method name | A name-only check could match a future built-in on another class; the class identity is stable. | -| Obsolete part page is still reported | The runtime failure is real even when the page is `ObsoleteState = Pending`; only obsolete test code (`ctx.IsObsolete()`) is skipped. | +| Identify the call by `invocation.Instance is ITestActionAccess`, not by method name or built-in class | `Enabled` and `Visible` also exist on `TestField` and `Invoke` on the built-in `OK()` path, so names alone cannot identify a page action; the access operation is the binder's statement that the receiver is a test-page action, and every such receiver has type `TestAction`, whose only members are `Invoke`, `Enabled` and `Visible`, so an extra `MethodKind` or class-name check adds nothing. A future fourth `TestAction` method is covered automatically. | +| No `ctx.IsObsolete()` gate; obsolete test code and obsolete part pages are both reported | The test runner still executes an `[Obsolete]` test method or an `ObsoleteState = Pending` test codeunit, and the action lookup still fails, so the usual "obsolete code is noise" rule would create a false negative on a test that breaks the run. | | Severity `Warning`, category `Usage` | The call always fails at runtime, but Warning (not Error) matches the convention for rules that do not prevent compilation. | | No CodeFix | The fix requires knowing the hosting page and its part control name; no mechanical rewrite exists. | | No `PageTypeKind` sentinel hardening | A missing `PageTypeKind` member would only cause silence (the equality check fails); the sentinel pattern is unnecessary here. | @@ -35,7 +35,6 @@ Registers `RegisterOperationAction` on `InvocationExpression`; main type `Invoke - Built-in actions `OK`/`Cancel`/`Yes`/`No`/`View`/`Edit`: `SubPage.OK().Invoke()` has an invocation (the `OK()` call) as the `Instance` of the outer `Invoke`, not an `ITestActionAccess`. - `HeadlinePart` and every other `PageType` not confirmed to fail. - `TestRequestPage`: its receiver type is `RequestPageTypeSymbol`, which is `IPageBaseTypeSymbol` but not `IPageTypeSymbol`; the `OriginalDefinition is not IPageTypeSymbol` cast bails out. -- Obsolete test code: `ctx.IsObsolete()` returns true when the enclosing method or object is obsolete, skipping the diagnostic. ## Test notes diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoleteTestMethod.al similarity index 100% rename from src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/NoDiagnostic/ObsoleteTestMethod.al rename to src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/HasDiagnostic/ObsoleteTestMethod.al diff --git a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs index 4c07217a..09d605cd 100644 --- a/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs +++ b/src/ALCops.TestAutomationCop.Test/Rules/InvokeActionOnPartTestPage/InvokeActionOnPartTestPage.cs @@ -28,6 +28,7 @@ public void Setup() [TestCase("ListPartInvokeWithoutParentheses")] [TestCase("ObsoletePartPage")] [TestCase("NamespacedListPart")] + [TestCase("ObsoleteTestMethod")] public async Task HasDiagnostic(string testCase) { SkipTestIfVersionIsTooLow( @@ -52,7 +53,6 @@ public async Task HasDiagnostic(string testCase) [TestCase("OpenViewOnPart")] [TestCase("BuiltInOkOnPart")] [TestCase("TestRequestPage")] - [TestCase("ObsoleteTestMethod")] public async Task NoDiagnostic(string testCase) { var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) diff --git a/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs b/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs index 490c4c0d..0294aea2 100644 --- a/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs +++ b/src/ALCops.TestAutomationCop/Analyzers/InvokeActionOnPartTestPage.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using ALCops.Common.Extensions; using ALCops.Common.Reflection; using Microsoft.Dynamics.Nav.CodeAnalysis; using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics; @@ -22,15 +21,13 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeInvocation(OperationAnalysisContext ctx) { - if (ctx.IsObsolete() || ctx.Operation is not IInvocationExpression invocation) - return; - - var method = invocation.TargetMethod; - if (method.MethodKind != EnumProvider.MethodKind.BuiltInMethod || - method.ContainingSymbol is not IClassTypeSymbol cls || - !SemanticFacts.IsSameName(cls.Name, "TestAction")) + // Obsolete test code is not skipped: the test runner still executes an obsolete test + // method or codeunit, and the action lookup still fails at runtime. + if (ctx.Operation is not IInvocationExpression invocation) return; + // A TestActionAccess receiver is an action of a test page; the built-in TestAction class has + // exactly Invoke, Enabled and Visible, all of which fail on a part page opened directly. // MainPage.Part.Action binds its receiver as a TestPart; only a TestPage receiver is a part opened directly. if (invocation.Instance is not ITestActionAccess access) return;