diff --git a/content/_index.md b/content/_index.md index 776ca694..5d6a83a4 100644 --- a/content/_index.md +++ b/content/_index.md @@ -125,14 +125,14 @@ images: ["images/og-image-fission.png"]
NEW

- Vibe Coding for AL + Getting Started With Agentic Coding

- AI-optimized coding rules and guidelines designed to enhance the AL developer experience in modern AI-powered IDEs like VS Code and Cursor. + Essential concepts and practices for working with AI coding assistants in your AL development workflow.

- - + +
@@ -140,16 +140,16 @@ images: ["images/og-image-fission.png"]
- TAG + GUIDE

- API + Vibe Coding Rules

- Two API Related Design Patterns and one Best Practice for working with API's + AI-optimized coding rules and guidelines designed to enhance the AL developer experience in modern AI-powered IDEs.

-
@@ -157,17 +157,17 @@ images: ["images/og-image-fission.png"]
- PATTERN + TOOLS

- Event Bridge + Agentic Tools

- In the world of interfaces, it is important to preserve (certain) events over multiple implementation of the interface. + Model Context Protocol (MCP) servers that enhance AI assistants for Business Central development workflows.

@@ -210,17 +210,6 @@ images: ["images/og-image-fission.png"]

- - -
@@ -254,22 +243,6 @@ images: ["images/og-image-fission.png"] -
- -
diff --git a/content/docs/BestPractices/api-page/index.md b/content/docs/BestPractices/api-page/index.md index b1dc4488..cc23701e 100644 --- a/content/docs/BestPractices/api-page/index.md +++ b/content/docs/BestPractices/api-page/index.md @@ -35,7 +35,7 @@ The properties that must be defined are: - ODataKeyFields ### APIPublisher -The name of the API publisher is usually the company creating the API. It is the first custom part in the URL for a given endpoint. The value is case insensitive. +The name of the API publisher is usually the company creating the API. It is the first custom part in the URL for a given endpoint. While the value is case insensitive for HTTP operations (GET, POST, etc.), it is case sensitive when checking for active subscriptions. Example: @@ -44,7 +44,7 @@ APIPublisher = 'contoso'; ``` ### APIGroup -Sets the group of the API endpoint that page or query is exposed in. In the URL the APIGroup comes after the APIPublisher. It can be used to distinguish different API apps or groups of APIs from each other. The value is case insensitive. +Sets the group of the API endpoint that page or query is exposed in. In the URL the APIGroup comes after the APIPublisher. It can be used to distinguish different API apps or groups of APIs from each other. While the value is case insensitive for HTTP operations (GET, POST, etc.), it is case sensitive when checking for active subscriptions. Example: diff --git a/content/docs/NAVPatterns/patterns/blocked-entity/_index.md b/content/docs/NAVPatterns/patterns/blocked-entity/_index.md index 79b0f5ac..402ba137 100644 --- a/content/docs/NAVPatterns/patterns/blocked-entity/_index.md +++ b/content/docs/NAVPatterns/patterns/blocked-entity/_index.md @@ -92,7 +92,9 @@ Entities where the Blocked Entity has been implemented include: The [Released Entity][anchor4]. -{{< youtube O2R-fTSup1o >}} +{{% alert title="Note" %}} +There was previously a video demonstration of this pattern, but it is no longer available. +{{% /alert %}} [anchor0]: 2260.BlockedEntityPattern.png [anchor1]: /navpatterns/1-patterns/blocked-entity/data-driven-blocked-entity/ diff --git a/content/docs/agentic-coding/CommunityResources/Tools/al-development-collection.md b/content/docs/agentic-coding/CommunityResources/Tools/al-development-collection.md new file mode 100644 index 00000000..7312b533 --- /dev/null +++ b/content/docs/agentic-coding/CommunityResources/Tools/al-development-collection.md @@ -0,0 +1,49 @@ +--- +title: "AL Development Collection" +linkTitle: "AL Development Collection" +weight: 1 +description: > + AI Native AL Development toolkit with 37 Agent Primitives for systematic Business Central development +--- + +## Overview + +The AL Development Collection provides a complete AI-native development toolkit for Microsoft Dynamics 365 Business Central. Instead of ad-hoc AI usage, you get systematic engineering through 37 Agent Primitives organized across 3 framework layers implementing the AI Native-Instructions Architecture. + +**Created by**: [Javier Armesto](https://github.com/javiarmesto) + +--- + +## Key Features + +**6 Role-Based Specialist Agents**: +- al-architect 🏗️ (solution design), al-developer 💻 (implementation), al-debugger 🐛 (troubleshooting) +- al-tester ✅ (TDD/quality), al-api 🌐 (API development), al-copilot 🤖 (AI features) + +**4 Orchestra System Agents** (Multi-agent TDD): +- al-conductor 🎭 (orchestration), al-planning-subagent 🔍 (research) +- al-implement-subagent 💻 (TDD implementation), al-review-subagent ✅ (validation) + +**9 Auto-Applied Instructions**: +- Code style, naming conventions, performance patterns +- Error handling, events, testing standards +- Context-aware loading via `applyTo` patterns + +**18 Agentic Workflows**: +- Environment setup (al-initialize, al-build) +- Development (al-events, al-pages, al-permissions) +- Analysis (al-diagnose, al-performance, al-migrate) +- Copilot features (al-copilot-capability, al-copilot-promptdialog, al-copilot-test) + +**Smart Complexity Routing (Experimental)**: +- 🟢 LOW → al-developer (direct implementation) +- 🟡 MEDIUM → al-conductor (TDD orchestration) +- 🔴 HIGH → al-architect → al-conductor (full design + TDD) + +--- + +## Links + +- **GitHub**: https://github.com/javiarmesto/AL-Development-Collection-for-GitHub-Copilot +- **Quick Start**: https://github.com/javiarmesto/AL-Development-Collection-for-GitHub-Copilot/blob/main/QUICK-START.md +- **Complete Development Flow**: https://github.com/javiarmesto/AL-Development-Collection-for-GitHub-Copilot/blob/main/docs/workflows/complete-development-flow.md diff --git a/content/docs/agentic-coding/CommunityResources/articles.md b/content/docs/agentic-coding/CommunityResources/articles.md index 02c52e86..44ec92a6 100644 --- a/content/docs/agentic-coding/CommunityResources/articles.md +++ b/content/docs/agentic-coding/CommunityResources/articles.md @@ -14,6 +14,21 @@ This page curates blog posts, articles, and written guides from the Business Cen ### Getting Started +#### "AI for BC Development - The Knowledge Gap That Ships to Production" — Kine +**Author**: Kine (blog.kine.cz) +**Published**: 2025 +**Summary**: A critical self-assessment guide on whether you are ready to validate what AI produces for BC development. Covers common AI mistakes in AL code (wrong field assignment, missing keys, reinventing standard libraries) and what BC knowledge areas you need to review AI output responsibly. + +**Key Takeaways**: +- Common AI mistakes in AL code that ship to production +- Self-assessment framework for AI-assisted development readiness +- BC knowledge areas required to review AI output responsibly +- Practical examples of wrong field assignments, missing keys, and reinvented standard libraries + +**Link**: https://blog.kine.cz/posts/bcdevelopmentserie-02b/ + +--- + #### "Vibe Coding — yes or no?" — Demiliani **Author**: Demiliani **Published**: 2025-08-21 diff --git a/content/docs/agentic-coding/GettingMore/refactoring.md b/content/docs/agentic-coding/GettingMore/refactoring.md new file mode 100644 index 00000000..6aaac1c6 --- /dev/null +++ b/content/docs/agentic-coding/GettingMore/refactoring.md @@ -0,0 +1,1041 @@ +--- +title: "Refactoring Legacy Code" +linkTitle: "Refactoring" +weight: 4 +description: > + Use AI to modernize and improve existing AL code while maintaining functionality +--- + +## Scenario + +You've inherited a legacy AL codeunit from an older Business Central version. The code works, but it: + +- Uses deprecated patterns and APIs +- Has poor structure and naming +- Lacks documentation and error handling +- Contains duplicated logic +- Is difficult to test and maintain + +You need to modernize this code while ensuring it continues to work correctly. + +## Goal + +Use AI to help you: + +- Identify refactoring opportunities +- Modernize deprecated APIs +- Improve code structure +- Enhance readability and maintainability +- Add tests to ensure no regression + +## The Legacy Code + +Here's a typical legacy codeunit that needs refactoring: + +```al +codeunit 50100 "Sales Order Management" +{ + procedure ProcessSalesOrder(DocNo: Code[20]) + var + SH: Record "Sales Header"; + SL: Record "Sales Line"; + C: Record Customer; + I: Record Item; + qty: Decimal; + amt: Decimal; + begin + if DocNo = '' then + exit; + + SH.Get(SH."Document Type"::Order, DocNo); + C.Get(SH."Sell-to Customer No."); + + if C.Blocked <> C.Blocked::" " then begin + Message('Customer is blocked!'); + exit; + end; + + SL.SetRange("Document No.", DocNo); + if SL.Find('-') then + repeat + if SL.Type = SL.Type::Item then begin + I.Get(SL."No."); + if I.Inventory < SL.Quantity then + Message('Not enough inventory for item ' + I."No."); + end; + qty := qty + SL.Quantity; + amt := amt + SL."Line Amount"; + until SL.Next() = 0; + + if amt > 10000 then begin + SL.SetRange("Document No.", DocNo); + if SL.Find('-') then + repeat + SL."Line Discount %" := 5; + SL.Modify(); + until SL.Next() = 0; + end; + + Message('Processed order ' + DocNo + ' with total amount ' + Format(amt)); + end; +} +``` + +## Step-by-Step Refactoring Process + +### Step 1: Write Tests First + +{{% alert title="Critical: Test First!" color="warning" %}} +Before refactoring any code, write tests that verify its current behavior. These tests act as a safety net, ensuring you don't accidentally break functionality during refactoring. +{{% /alert %}} + +**Prompt**: + +``` +I need to refactor this legacy code, but first I need comprehensive tests for its current behavior. + +Create tests that verify: +- All current functionality (even if imperfect) +- Expected outputs for given inputs +- Error conditions and edge cases +- Current behavior (not ideal behavior) + +These tests should pass with the current code and catch any regressions during refactoring. + +[Paste the legacy code] +``` + +**Expected Test Code**: + +```al +codeunit 50101 "Sales Order Mgmt. Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + + [Test] + procedure TestCurrentBehaviorWithValidOrder() + var + SalesHeader: Record "Sales Header"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A valid sales order (testing CURRENT behavior) + CreateTestSalesOrder(SalesHeader, 'CUST001', 5000); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] No error occurs and discount is applied + // (These tests lock in current behavior before refactoring) + Assert.IsTrue(true, 'Process completed without error'); + end; + + [Test] + procedure TestCurrentBlockedCustomerBehavior() + var + SalesHeader: Record "Sales Header"; + Customer: Record Customer; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] Order with blocked customer + CreateBlockedCustomerOrder(SalesHeader, Customer); + + // [WHEN] Processing (currently just shows Message, doesn't error) + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Process completes (testing CURRENT behavior, even if not ideal) + // Note: After refactoring, this might throw an error instead + end; + + // Additional tests for current behavior... +} +``` + +**Why This Matters**: + +- Tests catch regressions immediately +- You can refactor confidently +- Tests document current behavior +- You can run tests after each small refactoring step + +### Step 2: Initial Assessment + +**Prompt**: + +``` +Analyze this legacy AL code and identify refactoring opportunities. + +Categorize issues by: +- Critical: Deprecated APIs, security issues, bugs +- Major: Poor structure, missing error handling, performance issues +- Minor: Naming, formatting, documentation + +[Paste the legacy code] + +For each issue, explain: +- What the problem is +- Why it's problematic +- How to fix it +``` + +**Expected AI Findings**: + +**Critical Issues:** + +- Using `Message()` for errors instead of `Error()` +- No proper error handling (could cause partial updates) +- Using deprecated `Find('-')` syntax +- Modifying records in a loop without proper handling + +**Major Issues:** + +- Poor variable naming (SH, SL, C, I, qty, amt) +- Single large procedure doing multiple things +- Hard-coded business rules (10000 threshold, 5% discount) +- Inefficient database operations (multiple FindSet loops) +- No validation of input parameters +- Missing XML documentation +- No telemetry or logging + +**Minor Issues:** + +- No separation of concerns +- String concatenation for messages +- Magic numbers without explanation +- Inconsistent code style + +### Step 3: Create a Refactoring Plan + +**Prompt**: + +``` +Create a detailed refactoring plan for this code. + +I already have tests in place that verify current behavior. + +Break refactoring into phases: +1. Safe refactorings (no behavior change) +2. Structural improvements +3. API modernization +4. Behavioral improvements (may change behavior) +5. Final enhancements + +For each phase, list specific changes and estimated risk level. +After each phase, I'll run tests to ensure nothing broke. +``` + +**Expected Plan**: + +**Phase 0: Tests (COMPLETED)** + +- ✅ Written tests for current behavior +- ✅ Tests pass with current code +- ✅ Ready to refactor safely + +**Phase 1: Safe Refactorings (Low Risk)** + +- Rename variables to be descriptive +- Add XML documentation +- Extract magic numbers to constants +- Format code consistently +- Add code comments for complex logic + +**Phase 2: Structural Improvements (Medium Risk)** + +- Extract methods for each responsibility +- Separate validation, calculation, and update logic +- Remove code duplication +- Improve error handling + +**Phase 3: API Modernization (Medium Risk)** + +- Replace deprecated Find('-') with FindSet() +- Use proper Error() instead of Message() +- Add SetLoadFields for performance +- Use ModifyAll where appropriate + +**Phase 4: Behavioral Improvements (Medium Risk)** + +- Change Message() to Error() for validation failures +- This CHANGES behavior, so update tests accordingly +- Add new tests for improved error handling + +**Phase 5: Additional Testing (Low Risk)** + +- Add more edge case tests +- Add tests for each extracted method +- Expand test coverage + +**Phase 6: Enhancements (Low Risk)** + +- Add telemetry +- Implement proper transaction handling +- Add configuration instead of hard-coded values + +### Step 4: Run Your Tests + +**Before refactoring anything**, verify your tests pass: + +```powershell +# Run the test codeunit +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +**All tests should pass.** If they don't, fix the tests first. + +### Step 5: Phase 1 - Safe Refactorings + +**Prompt**: + +``` +Perform Phase 1 refactorings on this code: +- Rename all variables to be descriptive +- Add XML documentation to the procedure +- Extract magic numbers (10000, 5) to constants +- Add inline comments for complex logic +- Improve code formatting + +[Paste the legacy code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + LargeOrderThreshold: Decimal; + LargeOrderDiscountPct: Decimal; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Customer: Record Customer; + Item: Record Item; + TotalQuantity: Decimal; + TotalAmount: Decimal; + begin + // Initialize thresholds + LargeOrderThreshold := 10000; + LargeOrderDiscountPct := 5; + + // Validate input + if DocumentNo = '' then + exit; + + // Get sales order and customer + SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo); + Customer.Get(SalesHeader."Sell-to Customer No."); + + // Check if customer is blocked + if Customer.Blocked <> Customer.Blocked::" " then begin + Message('Customer is blocked!'); + exit; + end; + + // Check inventory and calculate totals + SalesLine.SetRange("Document No.", DocumentNo); + if SalesLine.Find('-') then + repeat + // Verify inventory availability for items + if SalesLine.Type = SalesLine.Type::Item then begin + Item.Get(SalesLine."No."); + if Item.Inventory < SalesLine.Quantity then + Message('Not enough inventory for item ' + Item."No."); + end; + + // Accumulate totals + TotalQuantity := TotalQuantity + SalesLine.Quantity; + TotalAmount := TotalAmount + SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + // Apply discount for large orders + if TotalAmount > LargeOrderThreshold then begin + SalesLine.SetRange("Document No.", DocumentNo); + if SalesLine.Find('-') then + repeat + SalesLine."Line Discount %" := LargeOrderDiscountPct; + SalesLine.Modify(); + until SalesLine.Next() = 0; + end; + + // Notify user of completion + Message('Processed order ' + DocumentNo + ' with total amount ' + Format(TotalAmount)); + end; +} +``` + +### Step 6: Run Tests After Phase 1 + +**Critical**: After completing Phase 1 refactorings, run your tests: + +```powershell +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +**All tests should still pass.** If any fail, the refactoring changed behavior - fix it before proceeding. + +### Step 7: Phase 2 - Structural Improvements + +**Prompt**: + +``` +Refactor this code to improve structure: + +1. Extract separate methods for: + - Customer validation + - Inventory checking + - Total calculation + - Discount application + +2. Replace Message() with proper Error() for validation failures + +3. Add proper error handling with try-catch where appropriate + +4. Use StrSubstNo for string formatting + +[Include the Phase 1 code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + LargeOrderThreshold: Decimal; + LargeOrderDiscountPct: Decimal; + InsufficientInventoryErr: Label 'Insufficient inventory for item %1. Available: %2, Required: %3'; + CustomerBlockedErr: Label 'Customer %1 is blocked and cannot place orders.'; + OrderProcessedMsg: Label 'Successfully processed order %1 with total amount %2.'; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + TotalAmount: Decimal; + begin + LargeOrderThreshold := 10000; + LargeOrderDiscountPct := 5; + + if DocumentNo = '' then + Error('Document number cannot be empty.'); + + if not SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then + Error('Sales order %1 not found.', DocumentNo); + + ValidateCustomer(SalesHeader); + CheckInventoryAvailability(SalesHeader); + TotalAmount := CalculateOrderTotal(SalesHeader); + + if TotalAmount > LargeOrderThreshold then + ApplyLargeOrderDiscount(SalesHeader); + + Message(OrderProcessedMsg, DocumentNo, Format(TotalAmount, 0, '')); + end; + + /// + /// Validates that the customer is not blocked + /// + /// The sales header to validate + local procedure ValidateCustomer(SalesHeader: Record "Sales Header") + var + Customer: Record Customer; + begin + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found.', SalesHeader."Sell-to Customer No."); + + if Customer.Blocked <> Customer.Blocked::" " then + Error(CustomerBlockedErr, Customer."No."); + end; + + /// + /// Checks that sufficient inventory is available for all items on the order + /// + /// The sales header to check + local procedure CheckInventoryAvailability(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + Item: Record Item; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetRange(Type, SalesLine.Type::Item); + + if SalesLine.FindSet() then + repeat + if Item.Get(SalesLine."No.") then + if Item.Inventory < SalesLine.Quantity then + Error(InsufficientInventoryErr, Item."No.", Item.Inventory, SalesLine.Quantity); + until SalesLine.Next() = 0; + end; + + /// + /// Calculates the total amount for the sales order + /// + /// The sales header to calculate totals for + /// The total amount of the order + local procedure CalculateOrderTotal(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetLoadFields("Line Amount"); + + if SalesLine.FindSet() then + repeat + TotalAmount += SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + exit(TotalAmount); + end; + + /// + /// Applies a discount to all lines on large orders + /// + /// The sales header to apply discounts to + local procedure ApplyLargeOrderDiscount(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.ModifyAll("Line Discount %", LargeOrderDiscountPct, true); + end; +} +``` + +### Step 8: Run Tests After Phase 2 + +Run your tests again after structural improvements: + +```powershell +Invoke-NAVCodeunit -CodeunitId 50101 +``` + +Tests should pass. If not, review the structural changes. + +### Step 9: Phase 3 - API Modernization + +**Prompt**: + +``` +Modernize this code to use current AL best practices: + +1. Replace deprecated Find('-') with FindSet() +2. Add proper SetLoadFields for performance +3. Use better error handling patterns +4. Implement proper transaction handling +5. Replace hard-coded values with setup table + +[Include the Phase 2 code] +``` + +**Expected Result**: + +```al +/// +/// Manages sales order processing including validation, inventory checking, and discount application +/// +codeunit 50100 "Sales Order Management" +{ + var + InsufficientInventoryErr: Label 'Insufficient inventory for item %1. Available: %2, Required: %3'; + CustomerBlockedErr: Label 'Customer %1 is blocked and cannot place orders.'; + OrderProcessedMsg: Label 'Successfully processed order %1 with total amount %2.'; + + /// + /// Processes a sales order by validating customer, checking inventory, and applying discounts + /// + /// The sales order number to process + procedure ProcessSalesOrder(DocumentNo: Code[20]) + var + SalesHeader: Record "Sales Header"; + TotalAmount: Decimal; + begin + if DocumentNo = '' then + Error('Document number cannot be empty.'); + + if not SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then + Error('Sales order %1 not found.', DocumentNo); + + ValidateCustomer(SalesHeader); + CheckInventoryAvailability(SalesHeader); + TotalAmount := CalculateOrderTotal(SalesHeader); + + if ShouldApplyLargeOrderDiscount(TotalAmount) then + ApplyLargeOrderDiscount(SalesHeader); + + LogOrderProcessing(DocumentNo, TotalAmount); + Message(OrderProcessedMsg, DocumentNo, Format(TotalAmount, 0, '')); + end; + + local procedure ValidateCustomer(SalesHeader: Record "Sales Header") + var + Customer: Record Customer; + begin + Customer.SetLoadFields(Blocked); + + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found.', SalesHeader."Sell-to Customer No."); + + if Customer.Blocked <> Customer.Blocked::" " then + Error(CustomerBlockedErr, Customer."No."); + end; + + local procedure CheckInventoryAvailability(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + Item: Record Item; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetRange(Type, SalesLine.Type::Item); + SalesLine.SetLoadFields("No.", Quantity); + + if SalesLine.FindSet() then begin + Item.SetLoadFields(Inventory); + repeat + if Item.Get(SalesLine."No.") then + if Item.Inventory < SalesLine.Quantity then + Error(InsufficientInventoryErr, Item."No.", Item.Inventory, SalesLine.Quantity); + until SalesLine.Next() = 0; + end; + end; + + local procedure CalculateOrderTotal(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.SetLoadFields("Line Amount"); + + if SalesLine.FindSet() then + repeat + TotalAmount += SalesLine."Line Amount"; + until SalesLine.Next() = 0; + + exit(TotalAmount); + end; + + local procedure ShouldApplyLargeOrderDiscount(TotalAmount: Decimal): Boolean + var + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + exit(TotalAmount > SalesSetup."Large Order Threshold"); + end; + + local procedure ApplyLargeOrderDiscount(SalesHeader: Record "Sales Header") + var + SalesLine: Record "Sales Line"; + SalesSetup: Record "Sales & Receivables Setup"; + begin + SalesSetup.Get(); + + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.ModifyAll("Line Discount %", SalesSetup."Large Order Discount %", true); + end; + + local procedure LogOrderProcessing(DocumentNo: Code[20]; TotalAmount: Decimal) + var + Telemetry: Codeunit Telemetry; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('DocumentNo', DocumentNo); + CustomDimensions.Add('TotalAmount', Format(TotalAmount)); + + Telemetry.LogMessage('SALESORD-001', 'Sales order processed successfully', + Verbosity::Normal, DataClassification::SystemMetadata, + TelemetryScope::ExtensionPublisher, CustomDimensions); + end; +} +``` + +### Step 10: Update Tests for Behavioral Changes + +Now that the code is cleaner, you may want to improve behavior (e.g., Error() instead of Message()): + +**Prompt**: + +``` +I want to change the behavior to use Error() instead of Message() for validation failures. + +First, update the tests to expect these errors: +- TestBlockedCustomerError should expect an error +- Update assertions to use asserterror and Assert.ExpectedError + +Then show the code changes needed. +``` + +### Step 11: Expand Test Coverage + +**Prompt**: + +``` +Now that refactoring is complete, add more comprehensive tests for edge cases. + +Include tests for: +- Happy path: successful processing +- Customer validation errors +- Inventory availability checks +- Large order discount application +- Edge cases: empty document number, non-existent order + +Use the AL test framework with proper setup and teardown. +``` + +**Expected Test Codeunit**: + +```al +codeunit 50101 "Sales Order Management Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; + + [Test] + procedure TestSuccessfulOrderProcessing() + var + SalesHeader: Record "Sales Header"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A valid sales order with sufficient inventory + CreateSalesOrderWithInventory(SalesHeader); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] No error is raised + // Verified by test not failing + end; + + [Test] + procedure TestBlockedCustomerError() + var + SalesHeader: Record "Sales Header"; + Customer: Record Customer; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A sales order for a blocked customer + CreateSalesOrderWithBlockedCustomer(SalesHeader, Customer); + + // [WHEN] Processing the order + asserterror SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Error is raised about blocked customer + Assert.ExpectedError(StrSubstNo('Customer %1 is blocked', Customer."No.")); + end; + + [Test] + procedure TestInsufficientInventoryError() + var + SalesHeader: Record "Sales Header"; + Item: Record Item; + SalesOrderMgmt: Codeunit "Sales Order Management"; + begin + // [GIVEN] A sales order with more quantity than available inventory + CreateSalesOrderWithInsufficientInventory(SalesHeader, Item); + + // [WHEN] Processing the order + asserterror SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Error is raised about insufficient inventory + Assert.ExpectedError('Insufficient inventory'); + end; + + [Test] + procedure TestLargeOrderDiscountApplied() + var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesSetup: Record "Sales & Receivables Setup"; + SalesOrderMgmt: Codeunit "Sales Order Management"; + ExpectedDiscount: Decimal; + begin + // [GIVEN] A large order that qualifies for discount + SalesSetup.Get(); + ExpectedDiscount := SalesSetup."Large Order Discount %"; + CreateLargeSalesOrder(SalesHeader); + + // [WHEN] Processing the order + SalesOrderMgmt.ProcessSalesOrder(SalesHeader."No."); + + // [THEN] Discount is applied to all lines + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.FindSet(); + repeat + Assert.AreEqual(ExpectedDiscount, SalesLine."Line Discount %", 'Discount not applied correctly'); + until SalesLine.Next() = 0; + end; + + local procedure CreateSalesOrderWithInventory(var SalesHeader: Record "Sales Header") + var + Item: Record Item; + SalesLine: Record "Sales Line"; + begin + LibraryInventory.CreateItem(Item); + Item.Inventory := 100; + Item.Modify(); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, LibrarySales.CreateCustomerNo()); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 10); + end; + + // Additional helper procedures... +} +``` + +## Review Points: What to Check + +After each refactoring phase, verify: + +### ✅ Functionality Preserved + +- [ ] All original functionality still works +- [ ] No regressions introduced +- [ ] Tests pass (or create tests first!) +- [ ] Edge cases still handled + +### ✅ Code Quality Improved + +- [ ] More readable and maintainable +- [ ] Better structure and organization +- [ ] Clearer naming and documentation +- [ ] Proper error handling + +### ✅ Performance Not Degraded + +- [ ] Database operations optimized +- [ ] No unnecessary loops or queries +- [ ] Proper use of SetLoadFields +- [ ] Efficient algorithms + +### ✅ Modern Practices Applied + +- [ ] Current AL syntax and APIs +- [ ] No deprecated features +- [ ] Proper telemetry +- [ ] Good separation of concerns + +## Advanced Refactoring Patterns + +### Extract Interface for Testability + +**Prompt**: + +``` +Refactor this codeunit to use interfaces for dependencies, making it more testable. + +Extract interfaces for: +- Customer validation +- Inventory checking +- Discount calculation + +This will allow us to mock these dependencies in tests. +``` + +### Convert to Event-Driven Architecture + +**Prompt**: + +``` +Refactor this code to use Business Central events: + +1. Publish events for: + - Before order processing + - After order validation + - Before discount application + - After order processing + +2. This allows other extensions to hook into the process +``` + +### Add Batch Processing Support + +**Prompt**: + +``` +Extend this codeunit to support batch processing of multiple orders. + +Include: +- Progress tracking +- Error handling per order (don't fail entire batch) +- Summary reporting +- Performance optimization for bulk operations +``` + +## Common Refactoring Challenges + +### Challenge 1: Unknown Business Logic + +**Problem**: Code has complex logic without documentation + +**Solution**: + +``` +Analyze this code and explain what business logic it implements: +[paste complex code] + +Then suggest how to make the logic clearer through refactoring. +``` + +### Challenge 2: Tightly Coupled Code + +**Problem**: Code has many dependencies that are hard to untangle + +**Solution**: + +``` +This code is tightly coupled. Create a refactoring plan to: +1. Identify dependencies +2. Extract interfaces +3. Use dependency injection +4. Make code more modular +``` + +### Challenge 3: Large Procedures + +**Problem**: Single procedure doing too many things + +**Solution**: + +``` +This procedure is too large and complex. +Apply the Single Responsibility Principle to break it into smaller procedures. +Each procedure should have one clear purpose. +``` + +## Best Practices for Refactoring with AI + +### 1. Always Write Tests First + +{{% alert title="Golden Rule" color="primary" %}} +**Never refactor without tests.** Tests are your safety net. Write them first, run them, then refactor. +{{% /alert %}} + +``` +Before we start refactoring, let's write tests that lock in the current behavior. +Even if the current behavior isn't perfect, we need to know if we change it. +``` + +### 2. Refactor in Small Steps + +Don't try to refactor everything at once. Use incremental changes: + +``` +Let's refactor this code in three phases: +Phase 1: Just improve naming and documentation +[Run tests - should pass] +Phase 2: Extract methods +[Run tests - should pass] +Phase 3: Modernize APIs +[Run tests - should pass] +``` + +### 3. Run Tests After Every Change + +``` +I've completed the naming refactoring. +Let me run the tests to make sure nothing broke. + +[Run tests] + +Great, tests pass. Now let's proceed to Phase 2. +``` + +### 3. Use Git Commits for Each Phase + +``` +We've completed Phase 1: Safe Refactorings. +Before moving to Phase 2, I'll commit these changes. +Suggest a good commit message for these refactorings. +``` + +### 4. Document Why, Not Just What + +``` +For each major refactoring, add a comment explaining WHY the change was made: +- Why was the old approach problematic? +- What does the new approach solve? +- Are there trade-offs? +``` + +### 5. Keep Performance in Mind + +``` +As we refactor this code, let's ensure we don't hurt performance. +For each database operation change, explain the performance implications. +``` + +## Practice Exercise + +Refactor this legacy code: + +```al +codeunit 50200 "Item Price Calculator" +{ + procedure CalcPrice(IN: Code[20]; CU: Code[10]; QT: Decimal): Decimal + var + I: Record Item; + C: Record Customer; + P: Decimal; + begin + I.Get(IN); + P := I."Unit Price"; + + if QT > 100 then + P := P * 0.9; + + if CU <> '' then begin + C.Get(CU); + if C."Customer Price Group" = 'VIP' then + P := P * 0.95; + end; + + exit(P); + end; +} +``` + +**Your Tasks**: + +1. Assess the code and list issues +2. Create a refactoring plan +3. Apply naming improvements +4. Extract methods for each responsibility +5. Add proper documentation and error handling +6. Create tests +7. Add telemetry + +## Next Steps + +- Learn about [writing tests](../testing) for your refactored code +- See how [code review](../code-review) catches refactoring issues +- Explore [adding telemetry](../telemetry) to monitor refactored code diff --git a/content/docs/agentic-coding/GettingMore/testing.md b/content/docs/agentic-coding/GettingMore/testing.md new file mode 100644 index 00000000..3c9cfae7 --- /dev/null +++ b/content/docs/agentic-coding/GettingMore/testing.md @@ -0,0 +1,902 @@ +--- +title: "Writing Tests" +linkTitle: "Testing" +weight: 5 +description: > + Use AI to create comprehensive test coverage for your AL code +--- + +## Scenario + +You've developed new features for your Business Central extension, but you need comprehensive test coverage to: + +- Ensure code works as expected +- Prevent regressions when making changes +- Document expected behavior +- Enable confident refactoring +- Meet quality standards + +Writing tests manually is time-consuming, and you want to use AI to accelerate the process while maintaining test quality. + +## Goal + +Use AI to help you: + +- Generate unit tests for individual procedures +- Create integration tests for complex workflows +- Design test data and scenarios +- Write test helpers and fixtures +- Create mock objects for dependencies +- Implement data-driven tests + +## The Code to Test + +Here's a codeunit that needs test coverage: + +```al +codeunit 50100 "Order Discount Manager" +{ + procedure CalculateDiscount(var SalesHeader: Record "Sales Header"): Decimal + var + Customer: Record Customer; + DiscountPct: Decimal; + begin + if not Customer.Get(SalesHeader."Sell-to Customer No.") then + Error('Customer %1 not found', SalesHeader."Sell-to Customer No."); + + DiscountPct := GetCustomerDiscount(Customer); + DiscountPct += GetVolumeDiscount(SalesHeader); + DiscountPct += GetSeasonalDiscount(); + + if DiscountPct > 50 then + DiscountPct := 50; + + exit(DiscountPct); + end; + + local procedure GetCustomerDiscount(Customer: Record Customer): Decimal + begin + case Customer."Customer Discount Group" of + 'VIP': + exit(10); + 'PREMIUM': + exit(5); + else + exit(0); + end; + end; + + local procedure GetVolumeDiscount(SalesHeader: Record "Sales Header"): Decimal + var + SalesLine: Record "Sales Line"; + TotalAmount: Decimal; + begin + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.CalcSums("Line Amount"); + TotalAmount := SalesLine."Line Amount"; + + case true of + TotalAmount >= 10000: + exit(15); + TotalAmount >= 5000: + exit(10); + TotalAmount >= 1000: + exit(5); + else + exit(0); + end; + end; + + local procedure GetSeasonalDiscount(): Decimal + begin + if Date2DMY(Today, 2) in [11, 12] then + exit(5); + exit(0); + end; +} +``` + +## Step-by-Step Testing Process + +### Step 1: Generate Basic Test Structure + +**Prompt**: + +``` +Create a test codeunit for the Order Discount Manager. + +Include: +- Proper test codeunit structure with Subtype = Test +- Setup and teardown if needed +- Test procedure stubs for all public procedures +- Use AL test framework conventions +- Include Assert codeunit +- Add test isolation attributes where needed + +[Include the codeunit code] +``` + +**Expected Result**: + +```al +codeunit 50101 "Order Discount Mgr. Tests" +{ + Subtype = Test; + + var + Assert: Codeunit "Library Assert"; + LibrarySales: Codeunit "Library - Sales"; + LibraryUtility: Codeunit "Library - Utility"; + IsInitialized: Boolean; + + [Test] + procedure TestCalculateDiscountBasic() + begin + // Test basic discount calculation + end; + + [Test] + procedure TestVIPCustomerDiscount() + begin + // Test VIP customer gets 10% discount + end; + + [Test] + procedure TestVolumeDiscountTiers() + begin + // Test volume discount calculation at different amounts + end; + + [Test] + procedure TestSeasonalDiscount() + begin + // Test seasonal discount in November/December + end; + + [Test] + procedure TestMaximumDiscountCap() + begin + // Test discount cannot exceed 50% + end; + + [Test] + procedure TestCustomerNotFoundError() + begin + // Test error when customer doesn't exist + end; + + local procedure Initialize() + begin + if IsInitialized then + exit; + + // Setup code here + IsInitialized := true; + end; +} +``` + +### Step 2: Implement Happy Path Test + +**Prompt**: + +``` +Implement the TestCalculateDiscountBasic test using the Given-When-Then pattern. + +This test should verify: +- A standard customer with a medium-size order +- Gets the expected discount percentage +- No errors are raised + +Use the Library codeunits for test data creation. +Include clear comments for each section. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCalculateDiscountBasic() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + ExpectedDiscount: Decimal; +begin + // [SCENARIO] Calculate discount for a standard order + Initialize(); + + // [GIVEN] A standard customer with no special discount group + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + // [GIVEN] A sales order with total amount of 2000 (5% volume discount) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 2000); + + // [GIVEN] Expected discount is 5% (volume only, not in seasonal months) + ExpectedDiscount := 5; + + // [WHEN] Calculating the discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] The discount matches expected value + Assert.AreEqual(ExpectedDiscount, ActualDiscount, 'Discount calculation incorrect'); +end; + +local procedure CreateSalesOrderWithAmount(CustomerNo: Code[20]; TotalAmount: Decimal): Record "Sales Header" +var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Item: Record Item; +begin + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, CustomerNo); + + Item."No." := LibraryUtility.GenerateRandomCode(Item.FieldNo("No."), Database::Item); + Item."Unit Price" := TotalAmount; + Item.Insert(); + + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + SalesLine.Validate("Unit Price", TotalAmount); + SalesLine.Modify(true); + + exit(SalesHeader); +end; +``` + +### Step 3: Implement Error Case Tests + +**Prompt**: + +``` +Implement the TestCustomerNotFoundError test. + +This should verify: +- When an invalid customer number is used +- An appropriate error is raised +- The error message contains the customer number + +Use asserterror and Assert.ExpectedError. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCustomerNotFoundError() +var + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + InvalidCustomerNo: Code[20]; +begin + // [SCENARIO] Error is raised when customer doesn't exist + Initialize(); + + // [GIVEN] A sales header with non-existent customer + InvalidCustomerNo := 'INVALID123'; + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, ''); + SalesHeader."Sell-to Customer No." := InvalidCustomerNo; + SalesHeader.Modify(); + + // [WHEN] Calculating discount for non-existent customer + asserterror OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] Appropriate error is raised with customer number + Assert.ExpectedError(StrSubstNo('Customer %1 not found', InvalidCustomerNo)); +end; +``` + +### Step 4: Implement Data-Driven Tests + +**Prompt**: + +``` +Create a comprehensive test for all customer discount tiers (VIP, PREMIUM, standard). + +Use a data-driven approach where one test function tests all scenarios. +Create a local helper that takes customer group and expected discount as parameters. + +Test these scenarios: +- VIP customer: 10% discount +- PREMIUM customer: 5% discount +- Standard customer: 0% discount +- Empty group: 0% discount +``` + +**Expected Result**: + +```al +[Test] +procedure TestCustomerDiscountTiers() +begin + // [SCENARIO] Different customer types receive appropriate discounts + Initialize(); + + // [GIVEN] [WHEN] [THEN] VIP customers get 10% discount + VerifyCustomerTypeDiscount('VIP', 10); + + // [GIVEN] [WHEN] [THEN] Premium customers get 5% discount + VerifyCustomerTypeDiscount('PREMIUM', 5); + + // [GIVEN] [WHEN] [THEN] Standard customers get 0% discount + VerifyCustomerTypeDiscount('STANDARD', 0); + + // [GIVEN] [WHEN] [THEN] Customers with no group get 0% discount + VerifyCustomerTypeDiscount('', 0); +end; + +local procedure VerifyCustomerTypeDiscount(CustomerGroup: Code[10]; ExpectedDiscount: Decimal) +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // Create customer with specific group + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := CustomerGroup; + Customer.Modify(); + + // Create order with amount below volume discount threshold (< 1000) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // Calculate discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify expected discount + Assert.AreEqual(ExpectedDiscount, ActualDiscount, + StrSubstNo('Incorrect discount for customer group %1', CustomerGroup)); +end; +``` + +### Step 5: Test Edge Cases and Boundaries + +**Prompt**: + +``` +Create tests for volume discount boundary conditions. + +Test these specific amounts: +- 999: Just below 1000 threshold (expect 0% volume discount) +- 1000: Exactly at threshold (expect 5% volume discount) +- 4999: Just below 5000 threshold (expect 5% volume discount) +- 5000: Exactly at threshold (expect 10% volume discount) +- 9999: Just below 10000 threshold (expect 10% volume discount) +- 10000: Exactly at threshold (expect 15% volume discount) + +Use descriptive test names and clear assertions. +``` + +**Expected Result**: + +```al +[Test] +procedure TestVolumeDiscountBoundaries() +begin + // [SCENARIO] Volume discounts applied at exact thresholds + Initialize(); + + // Just below first threshold + VerifyVolumeDiscount(999, 0, 'Below 1000 threshold'); + + // Exactly at first threshold + VerifyVolumeDiscount(1000, 5, 'At 1000 threshold'); + + // Between first and second threshold + VerifyVolumeDiscount(4999, 5, 'Below 5000 threshold'); + + // Exactly at second threshold + VerifyVolumeDiscount(5000, 10, 'At 5000 threshold'); + + // Between second and third threshold + VerifyVolumeDiscount(9999, 10, 'Below 10000 threshold'); + + // Exactly at third threshold + VerifyVolumeDiscount(10000, 15, 'At 10000 threshold'); +end; + +local procedure VerifyVolumeDiscount(OrderAmount: Decimal; ExpectedVolumeDiscount: Decimal; Scenario: Text) +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // Create standard customer (no customer discount) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + // Create order with specific amount + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", OrderAmount); + + // Calculate discount (assuming not in seasonal months) + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify volume discount + Assert.AreEqual(ExpectedVolumeDiscount, ActualDiscount, + StrSubstNo('Incorrect volume discount for %1 (amount: %2)', Scenario, OrderAmount)); +end; +``` + +### Step 6: Test Maximum Discount Cap + +**Prompt**: + +``` +Create a test that verifies the 50% maximum discount cap. + +Test scenario: +- VIP customer (10% discount) +- Large order (15% volume discount) +- Seasonal period (5% discount) +- Total would be 30%, which is under cap - verify this works +- Then test a scenario that would exceed 50% and verify it's capped + +Consider: What combination would exceed 50%? We may need to modify setup or mock GetSeasonalDiscount. +``` + +**Expected Result**: + +```al +[Test] +procedure TestMaximumDiscountCap() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // [SCENARIO] Discount is capped at 50% even when combined discounts exceed it + Initialize(); + + // [GIVEN] VIP customer (10% discount) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + // [GIVEN] Large order (15% volume discount) + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 10000); + + // [GIVEN] If testing in Nov/Dec, total would be 10+15+5=30% (under cap) + // [GIVEN] To test cap, we need a scenario that would exceed 50% + // Note: With current business logic, max is 30%, so cap isn't reached + + // [WHEN] Calculating discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] Discount doesn't exceed 50% + Assert.IsTrue(ActualDiscount <= 50, 'Discount exceeds maximum allowed 50%'); +end; + +[Test] +procedure TestDiscountCapWithMultipleDiscounts() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; +begin + // [SCENARIO] Combined discounts that would exceed 50% are capped + // [NOTE] This test may need the business logic modified to make it relevant + // Current max possible: 10% (VIP) + 15% (volume) + 5% (seasonal) = 30% + + Initialize(); + + // Create scenario with maximum possible discounts + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 10000); + + // Calculate discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // Verify cap is applied if needed + Assert.IsTrue(ActualDiscount <= 50, 'Discount cap not enforced'); +end; +``` + +### Step 7: Test Time-Dependent Logic + +**Prompt**: + +``` +Create tests for the seasonal discount that depends on the current date. + +Since GetSeasonalDiscount uses Today, we need to: +1. Test behavior for November (month 11) +2. Test behavior for December (month 12) +3. Test behavior for other months + +Explain how to handle date-dependent tests in AL. +Options: +- Make the date a parameter (refactor code) +- Use test isolation / mocking +- Create tests that work year-round +``` + +**Expected Result**: + +```al +[Test] +procedure TestSeasonalDiscountInNovember() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] Seasonal discount is applied in November + // [NOTE] This test only passes when run in November + + Initialize(); + + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth <> 11 then + exit; // Skip test if not November + + // [GIVEN] Standard customer with small order (no other discounts) + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // [WHEN] Calculating discount in November + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] 5% seasonal discount is applied + Assert.AreEqual(5, ActualDiscount, 'Seasonal discount not applied in November'); +end; + +[Test] +procedure TestNoSeasonalDiscountInJanuary() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] No seasonal discount in non-holiday months + + Initialize(); + + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth in [11, 12] then + exit; // Skip test if in seasonal period + + // [GIVEN] Standard customer with small order + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := ''; + Customer.Modify(); + + SalesHeader := CreateSalesOrderWithAmount(Customer."No.", 500); + + // [WHEN] Calculating discount outside seasonal period + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] No discount is applied + Assert.AreEqual(0, ActualDiscount, 'Unexpected discount outside seasonal period'); +end; + +// [RECOMMENDATION] Refactor GetSeasonalDiscount to accept date parameter +// This would make testing much easier and more reliable: +// local procedure GetSeasonalDiscount(CheckDate: Date): Decimal +``` + +### Step 8: Integration Test + +**Prompt**: + +``` +Create an integration test that tests the complete workflow: + +1. Create a new customer with VIP status +2. Create a sales order for that customer +3. Add multiple lines totaling over 10,000 +4. Calculate the discount +5. Verify all discount types are correctly combined +6. Verify the discount is applied to the sales header + +This test should verify the codeunit works correctly with real BC data and tables. +``` + +**Expected Result**: + +```al +[Test] +procedure TestCompleteDiscountWorkflow() +var + Customer: Record Customer; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + Item: Record Item; + OrderDiscountMgr: Codeunit "Order Discount Manager"; + ExpectedDiscount: Decimal; + ActualDiscount: Decimal; + CurrentMonth: Integer; +begin + // [SCENARIO] Complete workflow of discount calculation and application + Initialize(); + + // [GIVEN] A VIP customer + Customer.Get(LibrarySales.CreateCustomerNo()); + Customer."Customer Discount Group" := 'VIP'; + Customer.Modify(); + + // [GIVEN] A sales order with multiple lines totaling 12,000 + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + + // Add three items + CreateItemAndSalesLine(SalesHeader, 5000); + CreateItemAndSalesLine(SalesHeader, 4000); + CreateItemAndSalesLine(SalesHeader, 3000); + + // [GIVEN] Expected discount calculation: + // - VIP customer: 10% + // - Volume (>10000): 15% + // - Seasonal (if Nov/Dec): 5% + CurrentMonth := Date2DMY(Today, 2); + if CurrentMonth in [11, 12] then + ExpectedDiscount := 30 // 10 + 15 + 5 + else + ExpectedDiscount := 25; // 10 + 15 + + // [WHEN] Calculating the discount + ActualDiscount := OrderDiscountMgr.CalculateDiscount(SalesHeader); + + // [THEN] The correct combined discount is calculated + Assert.AreEqual(ExpectedDiscount, ActualDiscount, + 'Complete discount calculation incorrect'); + + // [THEN] Verify order total is correct + SalesLine.SetRange("Document Type", SalesHeader."Document Type"); + SalesLine.SetRange("Document No.", SalesHeader."No."); + SalesLine.CalcSums("Line Amount"); + Assert.AreEqual(12000, SalesLine."Line Amount", 'Order total incorrect'); +end; + +local procedure CreateItemAndSalesLine(SalesHeader: Record "Sales Header"; Amount: Decimal) +var + Item: Record Item; + SalesLine: Record "Sales Line"; +begin + Item."No." := LibraryUtility.GenerateRandomCode(Item.FieldNo("No."), Database::Item); + Item."Unit Price" := Amount; + Item.Insert(); + + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + SalesLine.Validate("Unit Price", Amount); + SalesLine.Modify(true); +end; +``` + +## Review Points: What to Check in Tests + +### ✅ Test Quality + +- [ ] Tests are independent (can run in any order) +- [ ] Tests are repeatable (same result every time) +- [ ] Tests have clear Given-When-Then structure +- [ ] Test names clearly describe what is being tested +- [ ] Assertions have descriptive error messages + +### ✅ Test Coverage + +- [ ] Happy path scenarios covered +- [ ] Error cases tested +- [ ] Edge cases and boundaries tested +- [ ] All public procedures have tests +- [ ] Integration scenarios tested + +### ✅ Test Maintainability + +- [ ] Helper methods reduce duplication +- [ ] Test data creation is centralized +- [ ] Tests are easy to understand +- [ ] Tests don't depend on specific data +- [ ] Setup and teardown properly implemented + +### ✅ Test Performance + +- [ ] Tests run quickly +- [ ] Minimal database operations +- [ ] Proper use of test isolation +- [ ] No unnecessary waits or delays + +## Advanced Testing Patterns + +### Pattern 1: Test Fixtures + +**Prompt**: + +``` +Create a test fixture class for sales orders that provides: +- Standard order (customer with no discounts, small amount) +- VIP order (VIP customer, medium amount) +- Large order (standard customer, large amount) +- Complex order (VIP customer, large amount, multiple lines) + +These fixtures should be reusable across all tests. +``` + +### Pattern 2: Mock Objects + +**Prompt**: + +``` +The GetSeasonalDiscount procedure is hard to test because it depends on Today. + +Refactor the code to use dependency injection: +1. Create an interface for date provider +2. Inject the date provider into the codeunit +3. Create a mock date provider for testing +4. Show how to test with different dates +``` + +### Pattern 3: Test Data Builders + +**Prompt**: + +``` +Create a fluent test data builder for sales orders: + +SalesOrderBuilder + .WithCustomer(CustomerNo) + .WithVIPStatus() + .WithLine(ItemNo, Quantity, Price) + .WithTotalAmount(Amount) + .Build() + +This makes test data creation more readable and flexible. +``` + +## Best Practices for AI-Assisted Testing + +### 1. Start with Test Cases, Then Generate + +``` +Before generating test code, help me identify all test cases for this procedure: +- What scenarios should be tested? +- What are the edge cases? +- What error conditions exist? +- What are the boundary conditions? + +[Include procedure code] +``` + +### 2. Generate Tests in Batches + +``` +Generate tests for these three related procedures together so they share test helpers: +- CalculateDiscount +- ApplyDiscount +- ValidateDiscount +``` + +### 3. Request Explanatory Comments + +``` +Generate the test with detailed comments explaining: +- Why this test case is important +- What could go wrong if this test didn't exist +- Any gotchas or special considerations +``` + +### 4. Ask for Test Improvements + +``` +Review this test I wrote. Suggest improvements for: +- Clarity and readability +- Better assertions +- Edge cases I might have missed +- Ways to make it more maintainable +``` + +### 5. Generate Test Documentation + +``` +Create documentation for this test suite explaining: +- What is being tested +- Test coverage summary +- How to run the tests +- How to add new tests +- Known limitations +``` + +## Common Testing Challenges + +### Challenge 1: Testing Private Methods + +**Problem**: Local procedures can't be tested directly + +**Solution**: + +``` +I need to test this local procedure. Options: +1. Make it public (if appropriate) +2. Test it indirectly through public procedures +3. Extract to a separate testable codeunit + +Which approach is best for this scenario? [Include code] +``` + +### Challenge 2: Testing Database Operations + +**Problem**: Tests that modify database are slow and fragile + +**Solution**: + +``` +This codeunit performs database operations. Help me: +1. Identify which operations need real database +2. Which can be mocked or isolated +3. Create a testing strategy that balances coverage and speed +``` + +### Challenge 3: Testing External Dependencies + +**Problem**: Code calls external services or APIs + +**Solution**: + +``` +This code calls an external API. Create: +1. An interface for the API +2. A mock implementation for testing +3. Tests using the mock +4. Integration tests for the real API (marked for manual runs) +``` + +## Practice Exercise + +Write comprehensive tests for this codeunit: + +```al +codeunit 50200 "Credit Limit Checker" +{ + procedure CheckCreditLimit(CustomerNo: Code[20]; NewOrderAmount: Decimal): Boolean + var + Customer: Record Customer; + CustLedgerEntry: Record "Cust. Ledger Entry"; + TotalOutstanding: Decimal; + begin + if not Customer.Get(CustomerNo) then + Error('Customer not found'); + + Customer.CalcFields("Balance (LCY)"); + TotalOutstanding := Customer."Balance (LCY)" + NewOrderAmount; + + if Customer."Credit Limit (LCY)" = 0 then + exit(true); + + exit(TotalOutstanding <= Customer."Credit Limit (LCY)"); + end; +} +``` + +**Your Tasks**: + +1. List all test scenarios +2. Create test codeunit structure +3. Implement tests for: + - Customer not found + - No credit limit (unlimited) + - Within credit limit + - Exactly at credit limit + - Over credit limit + - Edge cases +4. Add integration test +5. Review and improve tests + +## Next Steps + +- Learn how [code review](../code-review) can verify test quality +- See how [refactoring](../refactoring) benefits from good tests +- Explore [documentation](../documentation) for test procedures diff --git a/content/docs/patterns/no-series/index.md b/content/docs/patterns/no-series/index.md index a28c48ec..538a7eb9 100644 --- a/content/docs/patterns/no-series/index.md +++ b/content/docs/patterns/no-series/index.md @@ -10,6 +10,68 @@ _Created by Microsoft, Described by Jeremy Vyska (Spare Brained Ideas)_ The "Number Series" system is used extensively to provide numbers to master records, documents, and other transactions through Microsoft Dynamics 365 Business Central. +## Important: BC v24+ Modern Pattern (Updated 2024) + +**As of Business Central version 24.0 and later**, Microsoft deprecated the `NoSeriesManagement` codeunit in favor of the new `codeunit "No. Series"` with simplified methods. + +### Modern Implementation (BC v24+) + +**Variable declaration:** +```al +var + NoSeries: Codeunit "No. Series"; +``` + +**OnInsert pattern (simplified):** +```al +trigger OnInsert() +begin + if "No." = '' then begin + MySetup.Get(); + MySetup.TestField("Document Nos."); + "No. Series" := MySetup."Document Nos."; + if NoSeries.AreRelated(MySetup."Document Nos.", xRec."No. Series") then + "No. Series" := xRec."No. Series"; + "No." := NoSeries.GetNextNo("No. Series"); + end; +end; +``` + +**OnValidate pattern (same as before):** +```al +trigger OnValidate() +begin + if "No." <> xRec."No." then begin + MySetup.Get(); + NoSeries.TestManual(MySetup."Document Nos."); + "No. Series" := ''; + end; +end; +``` + +### Key Differences from Legacy Pattern + +| Legacy (NoSeriesManagement) | Modern (No. Series) | +|----------------------------|---------------------| +| `NoSeriesMgt.InitSeries(...)` - 5 parameters | `NoSeries.GetNextNo(...)` - 1-2 parameters | +| `NoSeriesMgt.TestManual(...)` | `NoSeries.TestManual(...)` - Same method name | +| `NoSeriesMgt.TryGetNextNo(...)` | `NoSeries.PeekNextNo(...)` - New name | +| `NoSeriesMgt.SelectSeries(...)` | `NoSeries.AreRelated(...)` - Simplified API | +| Complex parameter passing | Simplified, intuitive API | + +### Migration Strategy + +For **backward compatibility** (supporting both BC v23 and v24+): +1. Use conditional compilation with `#if` directives based on platform version +2. Check runtime platform version and branch logic accordingly +3. Implement both patterns in separate procedures with version detection + +{{% alert title="Note" color="warning" %}} +**The examples below reflect the LEGACY pattern** (NoSeriesManagement codeunit) for reference and historical context. For new development on BC v24+, use the modern `codeunit "No. Series"` pattern shown above. +{{% /alert %}} + +--- + ## Description At the heart of things, the Number Series engine allows users to define structure for a sequential numeric or alphanumeric string (collectively referred to as a 'number series'), then assign that structure to different parts of the system. @@ -283,4 +345,4 @@ For usage of number series, there is more information available on: - [Microsoft Docs: Create Number Series](https://docs.microsoft.com/en-us/dynamics365/business-central/ui-create-number-series) - [Microsoft Learn: Set up number series and trail codes](https://docs.microsoft.com/en-us/learn/modules/number-series-trail-codes-dynamics-365-business-central/) -For more programming details, there is more information on [Microsoft Docs: Number Sequences in Business Central](https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-number-sequences). \ No newline at end of file +For more programming details, there is more information on [Microsoft Docs: Number Sequences in Business Central](https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-number-sequences).