Address second round of Jesper Schulz-Wedde's review on PR #157

- log-writes-must-survive-rollback.good.al: fixed invalid trigger
  OnRun(var Rec: ...) declaration; Rec is implicit when TableNo is set.
- exposed-objects-must-be-in-a-permission-set.md: distinguished the three
  exposure mechanisms (page/query web service or API, codeunit published
  as a web service, [ServiceEnabled] bound action on a page) and their
  actual permission targets (page/query "..." = X vs codeunit "..." = X).
- code-must-not-change-workdate.md: scoped from an absolute "never" to
  "not as a side effect of unrelated logic" - verified real WorkDate(x)
  setter usage in BCApps demo-data generators and test codeunits.
- bcpt-scenarios-must-be-app-specific.md: SingleInstance and
  StartScenario/EndScenario reframed as context-dependent patterns, not
  mandatory requirements - BCPT Create Customer uses neither.
- test-feature-scenario-tags.good.al/.bad.al: replaced the invented
  LibrarySales.CreateCustomerWithPrice/"Item Price Mgt." calls with a real,
  verified price-list-line test using Library - Sales/Library - Inventory/
  Library - Price Calculation.
- page-design-must-match-bc-page-type-conventions.md: scoped the missing
  UsageCategory anti-pattern to pages intended as searchable entry points.
- defensive-vs-offensive-code-must-match-blast-radius.md/.good.al/.bad.al:
  replaced the VAT registration number "low blast radius" example with a
  genuinely cosmetic field (customer home page URL).
- source-organized-by-feature-not-object-type.md: anti-pattern reframed as
  inconsistency with a repo's own convention, not the object-type scheme
  itself.
- pictures-must-use-media-not-blob.md: removed leftover "image variants"
  wording contradicting the already-corrected MediaSet description.

Proactively fixed while sweeping all fixtures for invented APIs:
- given-blocks-must-cover-full-precondition-chain.bad.al: PostSalesOrder
  called with wrong arity and referenced an undeclared variable.
- ui-test-codeunit-naming.good.al/.bad.al: replaced the same fake
  "Item Price Mgt."/TestPage "Item Price" with real Library - Sales calls
  and the real Customer Card TestPage.

Worklist completeness: added review-skill cues for the 12 of 18 new rules
that had none (al-appsource-review.md, al-data-modeling-review.md,
al-error-handling-review.md, al-security-review.md, al-style-review.md x3,
al-testing-review.md x2, al-ui-review.md, al-upgrade-review.md,
al-web-services-review.md), and fixed test-feature-scenario-tags' cue,
which only matched the compliant (tagged) shape instead of the anti-pattern
(untagged/generic-named test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-08 20:05:56 +02:00
parent 0120b874b2
commit 3842ef7138
26 changed files with 219 additions and 89 deletions

View file

@ -13,28 +13,46 @@ application-area: [all]
The work date is a per-user session setting the user controls from the
client (the date shown in the top-right corner, used to default posting
dates and date filters). Application code must never call the `WorkDate`
function to set a new value. Doing so changes what the user sees and
defaults to for the rest of their session, as a side effect of running
unrelated business logic — a surprising, hard-to-trace behavior change the
user never asked for and has no visibility into.
dates and date filters). Business logic unrelated to that setting must not
call `WorkDate(NewDate)` as a side effect of doing something else — that
silently changes what the user sees and defaults to for the rest of their
session, a surprising, hard-to-trace behavior change the user never asked
for and has no visibility into. This is not a blanket ban on the setter
itself: BCApps' own demo-data generators legitimately save the current
work date, set a specific one to backdate the data they create, and
restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s
`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test
codeunits routinely set `WorkDate` deliberately to control the date context
a test runs under (hundreds of calls across BCApps' test suite, for
example `SustainabilityPostingTest.Codeunit.al`). Both are the code's
*actual purpose*, not a side effect of something unrelated.
This is a call-direction distinction: reading the current work date via
`WorkDate` (or `WorkDate()` with no argument) is fine and common — it is
only the assignment form, `WorkDate(NewDate)`, that is the anti-pattern.
This is a call-direction distinction for the read side: reading the
current work date via `WorkDate` (or `WorkDate()` with no argument) is
always fine.
## Best Practice
Read the work date to default a value; never write to it.
Read the work date to default a value. Only write to it when changing it
*is* the operation being performed — implementing the user's own
work-date/settings action, or a test or demo-data routine that deliberately
establishes a date context (saving and restoring the prior value if the
routine must leave the session as it found it). Business logic that exists
to do something else must never write `WorkDate` as an incidental side
effect; if a calculation needs a specific date, pass or compute that date
as a local variable instead.
See sample: `code-must-not-change-workdate.good.al`.
## Anti Pattern
Setting the work date from within a codeunit, report, or page action
changes session state the user owns, for the duration of a call that has
nothing to do with the user's date preference. If a scenario genuinely
needs a specific date for a calculation, pass or compute that date as a
local variable — never repurpose the session's `WorkDate`.
Setting the work date from within a codeunit, report, or page action whose
purpose is unrelated to the user's date preference — for example, a
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
own logic simpler. This changes session state the user owns for the
duration of a call that was never about the work date, and never restores
it. This is a different case from a test or demo-data routine explicitly
declaring a date context: the anti-pattern is unrelated logic silently
mutating state it does not own, not the setter form itself.
See sample: `code-must-not-change-workdate.bad.al`.

View file

@ -30,8 +30,8 @@ file attachment blob unrelated to picture rendering).
## Best Practice
Use `Media` (or `MediaSet` for multiple image variants) for any field that
holds a picture.
Use `Media` for a single image, or `MediaSet` for multiple independent
images, for any field that holds a picture.
See sample: `pictures-must-use-media-not-blob.good.al`.

View file

@ -1,6 +1,6 @@
// Both fields guarded the same way, out of habit rather than analysis.
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
VATRegNo := SalesHeader."VAT Registration No."; // low blast radius - fine
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page"; // low blast radius - fine
// but the same pattern, unexamined, was also applied here:
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then

View file

@ -1,8 +1,8 @@
// Low blast radius: guard, with an explicit chosen fallback.
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
VATRegNo := SalesHeader."VAT Registration No.";
if Customer.Get(SalesHeader."Sell-to Customer No.") then
CustomerHomePage := Customer."Home Page";
// Blank is an acceptable, deliberately-considered default here - the field
// is informational and a reviewer sees it before the document ships.
// is purely a display convenience and a reviewer sees it before the document ships.
// High blast radius: let it fail loud, because this feeds posted VAT.
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);

View file

@ -21,6 +21,6 @@ See sample: `defensive-vs-offensive-code-must-match-blast-radius.good.al`.
## Anti Pattern
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a VAT registration number shown only on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value.
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a customer's home page URL shown only for convenience on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value. A VAT registration number is not a safe stand-in for the low-risk side of this example: it is legally relevant, often validated, and can feed external VAT services or mandated document output, so it belongs on the offensive/fail-fast side alongside the posting group, not next to it as the "safe" contrast.
See sample: `defensive-vs-offensive-code-must-match-blast-radius.bad.al`.

View file

@ -15,7 +15,7 @@ codeunit 50100 "Sample Error Log Writer"
// session — there is no shared memory with the caller's instance.
TableNo = "Sample Error Log Buffer";
trigger OnRun(var Rec: Record "Sample Error Log Buffer")
trigger OnRun()
var
ErrorLogEntry: Record "Sample Error Log";
begin

View file

@ -11,11 +11,17 @@ application-area: [all]
## Description
An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, and each needs to be checked on its own terms: a page or query published through the **Web Services** configuration page; a custom REST endpoint declared with `PageType = API` / `QueryType = API`; or an individual codeunit method exposed with the `[ServiceEnabled]` attribute (a method-level attribute — it does not apply to pages or queries as a property). When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
An object that is reachable from outside the app's own UI is only usable if it is also granted execute access through a permission set. Three distinct mechanisms make an object reachable this way, each with its own permission target:
- A page or query published through the **Web Services** configuration page, or a custom REST endpoint declared with `PageType = API` / `QueryType = API` — both need a `page "..." = X` / `query "..." = X` entry for that object.
- A codeunit published through **Web Services** exposes *every* public procedure on it as an OData/SOAP operation automatically — there is no per-method attribute to add. The permission target is the codeunit itself: `codeunit "..." = X`.
- `[ServiceEnabled]` is a method-level attribute used on a *page* procedure to expose it as an OData v4 bound action (for example a `Post` action on an invoice page) — it does not apply to pages, queries, or codeunits as an object-level property, and it does not create its own permission target. The action is still a call into that page object, so the page's own `page "..." = X` entry is what governs it.
When such an object is left out of every permission set, it becomes both unusable (no caller, human or service, can reach it) and invisible in review: nobody deliberately decided who may call it. Exposure without a matching grant is not a safe default; it is an endpoint nobody is governing.
## Best Practice
Give every exposed object an explicit execute entry (`page "..." = X`, `query "..." = X`) in a permission set shipped by the app. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / `ServiceEnabled`) rather than leaving an orphaned endpoint with no permission-set membership.
Give every exposed object an explicit execute entry in a permission set shipped by the app: `page "..." = X` / `query "..." = X` for a published or API page/query (including one that exposes a `[ServiceEnabled]` bound action), and `codeunit "..." = X` for a codeunit published as a web service. Route sensitive endpoints into a dedicated, non-default admin permission set so reaching them requires a deliberate grant rather than being included by default. If an object should never be reachable from outside the app, remove the exposure itself (drop `PageType = API` / the Web Services registration) rather than leaving an orphaned endpoint with no permission-set membership.
See sample: `exposed-objects-must-be-in-a-permission-set.good.al`.

View file

@ -28,9 +28,21 @@ Each feature folder holds every object type it needs; shared code has one dedica
## Anti Pattern
A repository that documents or has established feature-based organization
as its convention, but then mixes in object-type folders for new work
anyway:
src/
├── Tables/
├── Pages/
├── Sales/
│ └── Invoice/
├── Tables/ <- new objects land here instead of a feature folder
└── Codeunits/
Finding everything related to one feature now requires searching multiple folders and mentally reassembling it from scattered pieces.
The anti-pattern is inconsistency with the project's own chosen convention,
not the object-type scheme itself — a repository that deliberately and
consistently organizes by object type throughout is exercising the other
reasonable choice described above, not violating this rule. What actually
costs a reader time is a codebase where some features live under their own
folder and others are scattered across type folders, so finding everything
related to one feature means checking both schemes and reassembling it from
wherever each object happened to land.

View file

@ -15,7 +15,7 @@ A PerformanceTest app that ships with only the generic Microsoft BCPT samples (c
## Best Practice
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit: `SingleInstance = true`, implementing `"BCPT Test Param. Provider"`, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()`, and building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Give each distinct step its own named scenario so a regression in one step doesn't hide inside a coarser measurement.
For every major business flow the extension adds, create a matching `BCPT*` scenario codeunit implementing `"BCPT Test Param. Provider"`, building its own test data in a local `InitTest()` procedure rather than depending on hardcoded records. Beyond that shared shape, the interface details are context-dependent, not fixed requirements: most of Microsoft's own shipped BCPT samples declare `SingleInstance = true`, but `codeunit "BCPT Create Customer"` does not, relying instead on `OnRun` calling `InitTest()` unconditionally every run. Likewise, wrapping the operation under test in `BCPTTestContext.StartScenario()` / `EndScenario()` is a real, available pattern for splitting one codeunit's run into several separately measured steps — useful when a regression in one step should not hide inside a coarser, whole-`OnRun` measurement — but it is not what every sample does; `"BCPT Create Customer"` measures its entire `OnRun` as a single implicit scenario and never calls `StartScenario`/`EndScenario` at all. Choose per-step scenarios when step-level granularity matters to the flow being tested; otherwise a single measured `OnRun` is a legitimate, simpler choice.
See sample: `bcpt-scenarios-must-be-app-specific.good.al`.

View file

@ -2,11 +2,14 @@
procedure PostSalesOrder_CreatesInvoice()
var
SalesHeader: Record "Sales Header";
SalesInvoiceHeader: Record "Sales Invoice Header";
InvoiceNo: Code[20];
begin
// [GIVEN] a sales order — posting groups left to whatever exists in the test company
LibrarySales.CreateSalesOrder(SalesHeader);
// [WHEN]
LibrarySales.PostSalesOrder(SalesHeader, false, true);
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, false, true);
// [THEN]
SalesInvoiceHeader.Get(InvoiceNo);
Assert.RecordIsNotEmpty(SalesInvoiceHeader);
end;

View file

@ -3,7 +3,9 @@ codeunit 50102 "Item Price Testing"
Subtype = Test;
var
ItemPriceMgt: Codeunit "Item Price Mgt.";
LibrarySales: Codeunit "Library - Sales";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
@ -11,11 +13,21 @@ codeunit 50102 "Item Price Testing"
var
Customer: Record Customer;
Item: Record Item;
Price, Disc: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// setup mixed with assertions, no clear layers
Customer.Insert(false);
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', Price, Disc);
Assert.AreEqual(100, Price, '');
// setup mixed with assertions, no clear layers, no FEATURE/SCENARIO/GIVEN/WHEN/THEN tags
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
end;
}

View file

@ -5,7 +5,8 @@ codeunit 50103 "Item Price Testing"
var
LibrarySales: Codeunit "Library - Sales";
ItemPriceMgt: Codeunit "Item Price Mgt.";
LibraryInventory: Codeunit "Library - Inventory";
LibraryPriceCalculation: Codeunit "Library - Price Calculation";
Assert: Codeunit "Library Assert";
[Test]
@ -13,14 +14,24 @@ codeunit 50103 "Item Price Testing"
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [SCENARIO] Customer with a specific price list line gets that unit price
// [GIVEN] a customer with a price list line at 100 LCY
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match customer price list');
// [GIVEN] a customer and an item with a customer-specific sales price list line
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] a sales line is created for that customer and item
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN] the sales line picks up the customer's price list line
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match customer price list');
end;
}

View file

@ -1,15 +1,32 @@
[Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
procedure GetPrice_ThenGetPriceLines_ReturnsCorrectValues()
var
TempBuffer: Record "Item Price Tier Buffer" temporary;
UnitPrice, LineDiscPct: Decimal;
Customer: Record Customer;
Item: Record Item;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] ...
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN] first action
ItemPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [WHEN] second action — this is a second test in disguise
ItemPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), '');
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", '');
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
Assert.AreEqual(2, PriceListLine.Count(), '');
end;

View file

@ -3,27 +3,51 @@ procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line";
begin
// [GIVEN] a customer with a price list line at 100
LibrarySales.CreateCustomerWithPrice(Customer, Item, '', 100);
// [GIVEN] a customer with a price list line for the item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
// [WHEN]
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
LibrarySales.CreateSalesDocumentWithItem(
SalesHeader, SalesLine, SalesHeader."Document Type"::Order, Customer."No.", Item."No.", 1, '', 0D);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
Assert.AreEqual(PriceListLine."Unit Price", SalesLine."Unit Price", 'Unit price must match price list');
end;
[Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
procedure GetPriceLines_TwoMinimumQuantityLines_ReturnsBoth()
var
Customer: Record Customer;
Item: Record Item;
TempBuffer: Record "Item Price Tier Buffer" temporary;
PriceListHeader: Record "Price List Header";
PriceListLine: Record "Price List Line";
begin
// [GIVEN] a customer with a tier price at min qty 10
LibrarySales.CreateCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [GIVEN] a customer price list with two minimum-quantity price lines for the same item
LibrarySales.CreateCustomer(Customer);
LibraryInventory.CreateItem(Item);
LibraryPriceCalculation.CreatePriceHeader(
PriceListHeader, PriceListHeader."Price Type"::Sale, "Price Source Type"::Customer, Customer."No.");
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 10);
PriceListLine.Modify(true);
LibraryPriceCalculation.CreateSalesPriceLine(
PriceListLine, PriceListHeader.Code, "Price Source Type"::Customer, Customer."No.",
"Price Asset Type"::Item, Item."No.");
PriceListLine.Validate("Minimum Quantity", 50);
PriceListLine.Modify(true);
// [WHEN]
ItemPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
PriceListLine.SetRange("Price List Code", PriceListHeader.Code);
// [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
Assert.AreEqual(2, PriceListLine.Count(), 'Exactly two price lines expected');
end;

View file

@ -3,22 +3,25 @@ codeunit 50104 "Item Price Testing"
Subtype = Test;
[Test]
procedure GetPrice_LogicTest()
procedure ApplyDiscount_LogicTest()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
Assert: Codeunit "Library Assert";
begin
// logic test — fine on its own, but not paired with a UI test below
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
Assert.AreEqual(90, ApplyDiscount(100, 10), 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
[Test]
procedure Page_ShowsPrice_UT()
procedure CustomerCard_Opens_UT()
var
ItemPricePage: TestPage "Item Price";
CustomerCard: TestPage "Customer Card";
begin
// UI test mixed into a logic-test codeunit, and the codeunit lacks the _UT suffix
ItemPricePage.OpenNew();
CustomerCard.OpenNew();
end;
}

View file

@ -3,13 +3,18 @@ codeunit 50105 "Item Price Testing"
Subtype = Test;
[Test]
procedure GetPrice_CustomerPrice_ReturnsUnitPrice()
procedure ApplyDiscount_ReducesUnitPrice()
var
Customer: Record Customer;
Item: Record Item;
UnitPrice, LineDiscPct: Decimal;
Assert: Codeunit "Library Assert";
DiscountedPrice: Decimal;
begin
ItemPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
DiscountedPrice := ApplyDiscount(100, 10);
Assert.AreEqual(90, DiscountedPrice, 'A 10% discount on 100 must yield 90');
end;
local procedure ApplyDiscount(UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
exit(UnitPrice - (UnitPrice * DiscountPct / 100));
end;
}
@ -18,16 +23,20 @@ codeunit 50106 "Item Price Testing_UT"
Subtype = Test;
[Test]
procedure Page_EnterCustomerAndItem_FactBoxShowsPrice()
procedure CustomerCard_SetName_UpdatesField()
var
Customer: Record Customer;
Item: Record Item;
ItemPricePage: TestPage "Item Price";
CustomerCard: TestPage "Customer Card";
Assert: Codeunit "Library Assert";
LibrarySales: Codeunit "Library - Sales";
begin
ItemPricePage.OpenNew();
ItemPricePage.CustomerNo.SetValue(Customer."No.");
ItemPricePage.ItemNo.SetValue(Item."No.");
Assert.AreEqual('100.00', ItemPricePage.PriceInfo.UnitPrice.Value(), '');
LibrarySales.CreateCustomer(Customer);
CustomerCard.OpenEdit();
CustomerCard.GoToRecord(Customer);
CustomerCard.Name.SetValue('Updated Name');
CustomerCard.Close();
Customer.Get(Customer."No.");
Assert.AreEqual('Updated Name', Customer.Name, 'Name must be updated through the page');
end;
}

View file

@ -75,9 +75,11 @@ page with no `CardPageID` even though a Card page exists for the same
table — signals a design step was skipped, not a stylistic choice. A
Card page over a composite-key table is not automatically this anti
pattern; check whether the table supplements a master record first. Also watch
for: a Worksheet or List page showing primary-key fields it shouldn't (or
hiding them when it should show them), and a page with no
`UsageCategory` set, which makes it invisible to Tell Me search even
though it otherwise works.
for a Worksheet or List page showing primary-key fields it shouldn't (or
hiding them when it should show them). A page with no `UsageCategory` set
is not automatically a defect either: supporting pages, subpages, dialogs,
and pages intended only to be reached through another workflow correctly
have no `UsageCategory` — flag its absence only on a page intended as a
searchable entry point in its own right.
See sample: `page-design-must-match-bc-page-type-conventions.bad.al`.