diff --git a/eval/COVERAGE.md b/eval/COVERAGE.md index 13c0e0c..4057c70 100644 --- a/eval/COVERAGE.md +++ b/eval/COVERAGE.md @@ -14,7 +14,7 @@ Every column is derived — families from `ObjectTypeRegistry`, capabilities fro file cannot claim coverage that no longer exists. Regenerate with `d365fo eval coverage --write`; CI runs `--check`. -**42 of 70 leaves complete.** +**42 of 71 leaves complete.** ## AOT families @@ -30,7 +30,7 @@ file cannot claim coverage that no longer exists. Regenerate with | `AxEdtExtension` | edtextension | — | ✅ | ✅ | — | `L2-edt-extension` | generate extension | | `AxEnum` | enum | ✅ | ✅ | ✅ | `event-handler-authoring`, `object-extension-authoring` | `L0-enum-basic`, `L0-enum-non-extensible` | generate enum | | `AxEnumExtension` | enumextension | — | ✅ | ✅ | — | `L2-enum-extension` | generate extension | -| `AxForm` | form | ✅ | ✅ | ✅ | `coc-extension-authoring`, `data-entity-scaffolding`, `event-handler-authoring`, `form-pattern-scaffolding`, `label-translation`, `model-dependency-and-coupling`, `object-extension-authoring`, `review-and-checkpoint-workflow`, `security-hierarchy-trace`, `table-scaffolding`, `x++-class-authoring`, `xpp-best-practice-rules`, `xpp-class-and-method-rules`, `xpp-database-queries`, `xpp-statement-and-type-rules` | `L1-form-basic`, `L1-form-details-master`, `L1-form-details-transaction`, `L1-form-dialog`, `L1-form-list-page`, `L1-form-lookup`, `L1-form-simplelist-details`, `L1-form-table-of-contents`, `L1-form-workspace`, `L2-control-method-basic`, `L2-datasource-method-basic` | generate form | +| `AxForm` | form | ✅ | ✅ | ✅ | `coc-extension-authoring`, `data-entity-scaffolding`, `event-handler-authoring`, `form-pattern-scaffolding`, `label-translation`, `model-dependency-and-coupling`, `object-extension-authoring`, `review-and-checkpoint-workflow`, `security-hierarchy-trace`, `table-scaffolding`, `x++-class-authoring`, `xpp-best-practice-rules`, `xpp-class-and-method-rules`, `xpp-database-queries`, `xpp-statement-and-type-rules` | `L1-form-basic`, `L1-form-details-master`, `L1-form-details-transaction`, `L1-form-dialog`, `L1-form-list-page`, `L1-form-lookup`, `L1-form-simplelist-details`, `L1-form-table-of-contents`, `L1-form-workspace`, `L2-control-method-basic`, `L2-datasource-method-basic`, `L2-form-clone-basic` | generate form | | `AxFormExtension` | formextension | ✅ | ✅ | ✅ | `form-pattern-scaffolding`, `review-and-checkpoint-workflow` | `L2-form-extension-basic` | generate extension | | `AxFormPart` | formpart | — | — | — | — | — | — | | `AxLabelFile` | labelfile | ✅ | — | — | `label-translation` | — | — | @@ -75,6 +75,7 @@ whether it is taught and proven. | `generate form` | AxForm | ✅ | ✅ | `form-pattern-scaffolding` | `L1-form-basic`, `L1-form-details-master`, `L1-form-details-transaction`, `L1-form-dialog`, `L1-form-list-page`, `L1-form-lookup`, `L1-form-simplelist-details`, `L1-form-table-of-contents`, `L1-form-workspace` | | `generate datasource-method` | AxForm | — | ✅ | — | `L2-datasource-method-basic` | | `generate control-method` | AxForm | — | ✅ | — | `L2-control-method-basic` | +| `generate form-clone` | AxForm | — | ✅ | — | `L2-form-clone-basic` | | `generate simple-list` | deprecated alias | — | — | — | — | | `generate entity` | AxDataEntityView | ✅ | ✅ | `data-entity-scaffolding`, `integration-dmf-dualwrite`, `integration-patterns` | `L2-virtual-entity-basic` | | `generate extension` | AxTableExtension, AxFormExtension, AxEdtExtension, AxEnumExtension, AxViewExtension, AxQuerySimpleExtension, AxDataEntityViewExtension, AxSecurityDutyExtension, AxSecurityRoleExtension | ✅ | ✅ | `forms-and-navigation`, `object-extension-authoring`, `xpp-best-practice-rules` | `L2-edt-extension`, `L2-enum-extension`, `L2-form-extension-basic`, `L2-security-duty-extension`, `L2-security-role-extension`, `L2-table-extension` | @@ -106,6 +107,7 @@ cannot build at all are omitted — they are a generation gap, not a coverage ga - **control-method** (capability) — no topic names it - **datasource-method** (capability) — no topic names it +- **form-clone** (capability) — no topic names it - **map** (capability) — no topic names it - **simple-list** (capability) — no topic names it; no case with a reviewed golden - **systest** (capability) — no topic names it diff --git a/eval/cases/L2-form-clone-basic.json b/eval/cases/L2-form-clone-basic.json new file mode 100644 index 0000000..e4789c2 --- /dev/null +++ b/eval/cases/L2-form-clone-basic.json @@ -0,0 +1,13 @@ +{ + "id": "L2-form-clone-basic", + "title": "Clone a two-datasource details-transaction form under a new name", + "tier": 2, + "instruction": "Clone the existing form eval/goldens/L1-form-details-transaction/ConFmVehicleServiceOrder.xml as ConFmVehicleServiceOrderInquiry. The clone keeps the original's pattern, both datasources and their join, and its whole control tree — only the form's own self-references (root name, class declaration, formStr) take the new name. Use only the d365fo CLI (search/get/generate/validate) — do not hand-write XML.", + "canonical_args": ["generate", "form-clone", "ConFmVehicleServiceOrderInquiry", "--from", "eval/goldens/L1-form-details-transaction/ConFmVehicleServiceOrder.xml"], + "target_artifact_types": ["AxForm"], + "golden_path": "L2-form-clone-basic", + "tags": ["form-clone", "form", "metadata"], + "ignore": [], + "requires_fixture_index": false, + "golden_pending": false +} diff --git a/eval/goldens/L2-form-clone-basic/ConFmVehicleServiceOrderInquiry.xml b/eval/goldens/L2-form-clone-basic/ConFmVehicleServiceOrderInquiry.xml new file mode 100644 index 0000000..5bfe728 --- /dev/null +++ b/eval/goldens/L2-form-clone-basic/ConFmVehicleServiceOrderInquiry.xml @@ -0,0 +1,411 @@ + + + + ConFmVehicleServiceOrderInquiry + + + + classDeclaration + + + + + + + + + + FmVehicle + FmVehicle
+ + + No + + +
+ + + + + + + + + + + FmVehicleLineFmVehicleLine
FmVehicleActiveNo
+
+ + + Vertical + Vehicle service order + 1 + Fixed + FmVehicle + DetailsTransaction + 1.4 + + FmVehicle + + + ActionPane + 134217727 + %1 + ActionPane + -1 + + + + ButtonGroup + ButtonGroup + + + Vertical + + + No + No + Vertical + + + + NavigationList + -1 + SizeToAvailable + Group + No + -1 + SizeToContent + + + + + NavigationListQuickFilter + SizeToAvailable + + QuickFilterControl + + + + targetControlName + String + NavigationListGrid + + + + + + NavigationListGrid + No + -1 + SizeToAvailable + Grid + -1 + SizeToContent + + + FmVehicle + No + No + + + + Vertical + 1 + Fixed + None + + + + PanelTab + -1 + SizeToAvailable + Tab + -1 + SizeToAvailable + + + + DetailsPanel + TabPage + + + + TitleGroup + -1 + SizeToContent + Group + -1 + SizeToAvailable + + + + HeaderTitle + Yes + String + SizeToAvailable + + VIN + FmVehicle + No + + + + HorizontalLeft + 1 + Fixed + FmVehicle + None + + + + HeaderLinePanels + -1 + SizeToAvailable + Tab + -1 + SizeToAvailable + + + + LinesPanel + TabPage + + + + LineViewTab + -1 + SizeToAvailable + Tab + -1 + SizeToAvailable + + + + LineViewHeader + TabPage + + + Yes + Auto + + + + + + + + + + + + LineViewLines-1SizeToAvailableTabPage + + LineViewLinesToolbar + -1 + SizeToContent + ActionPane + -1 + SizeToAvailable + + + + + + LineViewLinesGrid + -1 + SizeToAvailable + Grid + -1 + SizeToAvailable + + + Overview + FmVehicleLine + + 5 + Fixed + + YesAlwaysAuto + + LineViewLineDetails + TabPage + + + + LineDetailsTab + -1 + SizeToAvailable + Tab + -1 + SizeToAvailable + + + + + + + + + + LineDetailTabPageTabPageLine detailsAuto + + Yes + + + + Yes + Auto + + + + + + Vertical + 1 + Fixed + Yes + DetailsLine + + + HeaderPanel + TabPage + + + + HeaderDetailsTab + -1 + SizeToAvailable + Tab + -1 + SizeToAvailable + + + + + + + + + + HeaderGeneralPageTabPage + + HeaderGeneralGroup + Group + + + + Header_VIN + String + + VIN + FmVehicle + + + + Overview + FmVehicle + + GeneralAuto + + + + + 1 + Fixed + Yes + DetailsHeader + + + No + + + + Vertical + 1 + Fixed + Details + + + + GridPanel + TabPage + + + + CustomAndQuickFilterGroup + CustomAndQuickFilters + 1.1 + Group + SizeToAvailable + + + + QuickFilterControl + SizeToAvailable + + QuickFilterControl + + + + targetControlName + String + MainGrid + + + + + + HorizontalLeft + None + + Edit + + + MainGrid + Yes + -1 + SizeToAvailable + Grid + -1 + SizeToAvailable + + + FmVehicle + MainGridDefaultAction + + + + MainGridDefaultAction + CommandButton + No + + DetailsView + + + Vertical + 1 + Fixed + Grid + + + + No + + + + + +
diff --git a/src/D365FO.Core/FormPatterns/FormCloner.cs b/src/D365FO.Core/FormPatterns/FormCloner.cs index 5114124..2527eef 100644 --- a/src/D365FO.Core/FormPatterns/FormCloner.cs +++ b/src/D365FO.Core/FormPatterns/FormCloner.cs @@ -141,12 +141,12 @@ private static (List Rebound, List Renamed) RebindTables( xml = Regex.Replace(xml, tableTag, $"{newTable}
"); rebound.Add($"{oldTable} -> {newTable}"); - // The datasource element that carried the table, and every control pointing at it. + // The datasource element that carried the table, and everything pointing at it. var dsName = $"{Regex.Escape(oldTable)}"; if (Regex.IsMatch(xml, dsName)) { xml = Regex.Replace(xml, dsName, $"{newTable}"); - xml = Regex.Replace(xml, $"{Regex.Escape(oldTable)}", $"{newTable}"); + xml = RenameDataSourceReferences(xml, oldTable, newTable); renamed.Add($"{oldTable} -> {newTable}"); } @@ -159,6 +159,48 @@ private static (List Rebound, List Renamed) RebindTables( return (rebound, renamed); } + /// + /// Elements whose text is the name of a datasource, so a renamed datasource has to be + /// followed into all of them. + /// + /// + /// Grounded on a live installation rather than guessed: across 300 shipped + /// ApplicationSuite\Foundation\AxForm forms these are the only elements carrying a + /// datasource name as their value. The rest of the *DataSource* family is either a + /// container (DataSources, DataSourceLinks, ReferencedDataSources) or + /// holds something else entirely (DataSourceChangeGroupMode is an enum, + /// DataSourceRelation names a relation). Datasource names reached through a nested + /// <Name> — root links, referenced and derived datasources — are already covered + /// by the <Name> rewrite above. + /// + private static readonly string[] DataSourceRefElements = + ["DataSource", "TitleDataSource", "WorkflowDataSource", "PresenceDataSource", "JoinSource"]; + + /// + /// Repoint every reference to a renamed datasource, whatever attributes the tag carries. + /// + /// + /// The attribute tolerance is the whole point. A <Design>'s own + /// DataSource/TitleDataSource are first-level children written in the empty + /// namespace, so they appear as <DataSource xmlns="">, while the control-level + /// ones nested under <Controls xmlns=""> inherit it and appear bare. Matching only + /// the bare form rebinds the controls and leaves the design pointing at a datasource the + /// clone no longer has. + /// + /// The alternation cannot bleed into a longer tag: the group must be followed immediately by + /// whitespace or >, so DataSource never matches the start of + /// DataSourceLinks. The closing tag is a backreference, so the two always agree. + /// + /// + private static string RenameDataSourceReferences(string xml, string oldName, string newName) + { + var tags = string.Join('|', DataSourceRefElements); + return Regex.Replace( + xml, + $@"<({tags})((?:\s[^>]*)?)>{Regex.Escape(oldName)}", + m => $"<{m.Groups[1].Value}{m.Groups[2].Value}>{newName}"); + } + /// /// The clone has to still be a parseable form carrying its new name. /// diff --git a/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs b/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs index c7a6d78..1e4707b 100644 --- a/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs +++ b/tests/D365FO.Cli.Tests/Eval/EvalListCommandTests.cs @@ -33,9 +33,10 @@ public void Lists_every_authored_case() Assert.Equal(0, exit); Assert.Contains("\"ok\":true", stdout); - Assert.Contains("\"count\":51", stdout); + Assert.Contains("\"count\":52", stdout); Assert.Contains("L0-edt-basic", stdout); Assert.Contains("L2-coc-extension", stdout); Assert.Contains("L1-form-workspace", stdout); + Assert.Contains("L2-form-clone-basic", stdout); } } diff --git a/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs b/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs index e71c1a9..8e38630 100644 --- a/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs +++ b/tests/D365FO.Core.Tests/Eval/EvalCaseCatalogTests.cs @@ -14,7 +14,7 @@ public void Loads_the_real_authored_catalog_with_no_errors() var (cases, errors) = EvalCaseCatalog.LoadAll(EvalPaths.CasesDir(RepoRoot)); Assert.Empty(errors); - Assert.Equal(51, cases.Count); + Assert.Equal(52, cases.Count); Assert.Contains(cases, c => c.Id == "L0-edt-basic"); Assert.Contains(cases, c => c.Id == "L0-enum-basic"); Assert.Contains(cases, c => c.Id == "L1-table-basic"); diff --git a/tests/D365FO.Core.Tests/FormClonerTests.cs b/tests/D365FO.Core.Tests/FormClonerTests.cs index 5ae6dbf..5f8ceab 100644 --- a/tests/D365FO.Core.Tests/FormClonerTests.cs +++ b/tests/D365FO.Core.Tests/FormClonerTests.cs @@ -11,7 +11,10 @@ namespace D365FO.Core.Tests; /// /// The fixture mirrors the shape of a real shipped form: the root name, the X++ class /// declaration, a datasource entry under <SourceCode> (where override methods live), -/// the design datasource, and a control pointing at it by name. Verified against +/// the design datasource, the design's own DataSource/TitleDataSource properties +/// (first-level children of <Design>, so they carry an explicit xmlns="" where +/// the control-level ones inherit it), and a control pointing at the datasource by name. Verified +/// against /// ApplicationSuite\Foundation\AxForm\CustGroup.xml on a live installation — a 16 KB form /// where the clone differs from the source on exactly the intended lines and is byte-identical /// everywhere else. @@ -41,8 +44,15 @@ public class FormClonerTests CustGroup CustGroup
+ + CustTrans + CustTrans
+ CustGroup +
+ CustGroup + CustGroup Grid_CustGroupId CustGroupId @@ -89,6 +99,61 @@ public void A_rebind_moves_the_table_the_datasource_and_every_control_that_names Assert.Single(result.RenamedDataSources); } + [Fact] + public void A_rebind_follows_the_datasource_into_the_design_properties_that_name_it() + { + // The design's own DataSource/TitleDataSource are first-level children of , so + // they are written as . A pattern anchored on the bare "" + // misses them, and the clone then names a datasource it no longer has — a form that + // compiles and breaks at runtime. Grounded on a live installation: of 300 shipped + // ApplicationSuite forms, 128 and 78 nodes carry attributes. + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Contains("ConVehicleGroupTable", result.Xml); + Assert.Contains("ConVehicleGroupTable", result.Xml); + Assert.DoesNotContain("CustGroup", result.Xml); + Assert.DoesNotContain("CustGroup", result.Xml); + } + + [Fact] + public void A_rebind_follows_the_datasource_into_the_join_that_names_it() + { + // A joined datasource names its parent by datasource name, so renaming the parent has to + // reach the join too — otherwise the child joins to something that no longer exists. + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Contains("ConVehicleGroupTable", result.Xml); + } + + [Fact] + public void A_tag_that_merely_starts_with_a_matched_element_name_is_not_rewritten() + { + // The alternation must not let "DataSource" bleed into "DataSourceLinks" and friends. + var withLinks = Source.Replace( + "CustGroup", + "CustGroupCustGroup"); + + var result = FormCloner.Clone(withLinks, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + Assert.Contains("CustGroup", result.Xml); + } + + [Fact] + public void A_rebind_leaves_no_control_or_design_node_naming_the_old_datasource() + { + // The whole-document guarantee behind the case above: after a rebind that renamed the + // datasource, nothing anywhere still refers to the old name. + var result = FormCloner.Clone(Source, "ConVehicleGroup", Rebind("CustGroup", "ConVehicleGroupTable")); + + var stillNamingOld = XDocument.Parse(result.Xml) + .Descendants() + .Where(e => e.Value.Trim() == "CustGroup" && !e.HasElements) + .Select(e => e.Name.LocalName) + .ToList(); + + Assert.Empty(stillNamingOld); + } + [Fact] public void The_form_is_renamed_before_the_rebind_so_the_root_never_takes_the_table_name() {