diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index cabdf10956..03bec31a97 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1604,24 +1604,26 @@ public DetailField( public int ObjectHvo { get; } /// - /// The class of the compiled view definition this row was projected from (advanced-entry-view): - /// the entry's own fields carry "LexEntry"; a row from a descended object (a sense, an - /// allomorph) - /// carries that object's layout class. Paired with it keys the per-project - /// ViewDefinitionOverride store so the per-field gear-menu commands (Field - /// Visibility / Move - /// Field) target the right layout. Set by the composer at compose time (null on rows built outside - /// the full-entry composer, e.g. the first-slice fallback). + /// The class of the compiled view definition this row was projected from. The entry's own + /// fields carry "LexEntry"; a row from a descended object carries that object's layout + /// class. + /// Paired with , it identifies the exact legacy layout command + /// target. + /// Set by the composer at compose time; null on rows built outside the full-entry + /// composer. /// public string ClassName { get; set; } /// - /// The layout name of the compiled view definition this row was projected from (e.g. - /// "Normal"). - /// See . + /// The layout name of the compiled view definition this row was projected from, such as + /// "Normal". See . /// public string LayoutName { get; set; } + /// The owning caller part's structural address in the effective legacy + /// layout. + public string SourceCallerPath { get; set; } + /// /// The project's available CHARACTER-type style names /// the per-WS editor offers when restyling a selection (sourced by the composer from the project's diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index b582547709..f2ec86968f 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,8 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product); + source.LocalizationKey, automationId, HostRouting.Product, + sourceCallerPath: source.SourceCallerPath); private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs deleted file mode 100644 index 54e8c63524..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.Linq; -using Avalonia.Automation; -using Avalonia.Controls; -using Avalonia.Headless.NUnit; -using Avalonia.Threading; -using Avalonia.VisualTree; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.Detail; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// advanced-entry-view (view layer): the per-field gear-menu commands work by changing the composed - /// model the detail view renders -- hiding a Never row, showing a non-empty IfData row, and - /// reordering - /// siblings. These headless tests prove the renders EXACTLY the - /// rows the (patched) model carries, in model order. The composer's filtering/reorder semantics are - /// covered in xWorksTests; here we prove the visible detail view follows the model so the round trip is - /// closed at the rendering edge. - /// - [TestFixture] - public class DetailOverrideRenderingTests - { - private static DetailField TextField(string id, string label) - => new DetailField(id, label, label, null, DetailFieldKind.Text, - EditorClassification.Known, id, null, HostRouting.Inherit, - new List { new DetailWsValue("en", "value") }, - null, null, isEditable: true, indent: 0, objectHvo: 1234); - - private static DataTree Render(params DetailField[] fields) - { - var model = new DetailModel("LexEntry", "Normal", fields.ToList(), - new List()); - var view = new DataTree(model, null, null, null, null, null); - var window = new Window { Content = view, Width = 480, Height = 360 }; - window.Show(); - Dispatcher.UIThread.RunJobs(); - return view; - } - - private static List RenderedLabelIds(DataTree view) - => view.GetVisualDescendants().OfType() - .Select(t => AutomationProperties.GetAutomationId(t)) - .Where(id => !string.IsNullOrEmpty(id) && id.EndsWith(".Label")) - .ToList(); - - [AvaloniaTest] - public void DetailView_RendersOnlyTheRowsInTheModel() - { - // A model with "B" hidden (as a Never visibility override would drop it from compose) shows - // only A and C. - var view = Render(TextField("a", "Alpha"), TextField("c", "Gamma")); - - var labels = RenderedLabelIds(view); - Assert.That(labels, Has.Member("a.Label")); - Assert.That(labels, Has.Member("c.Label")); - Assert.That(labels, Has.No.Member("b.Label"), - "a row omitted from the model (a hidden field) does not render"); - } - - [AvaloniaTest] - public void DetailView_RendersRowsInModelOrder_SoAReorderIsVisible() - { - // The reorder override produces a model whose fields are in the new order; the view must - // follow that order top-to-bottom. - var view = Render(TextField("c", "Gamma"), TextField("a", "Alpha"), TextField("b", "Beta")); - - var order = RenderedLabelIds(view); - Assert.That(order.IndexOf("c.Label"), Is.LessThan(order.IndexOf("a.Label"))); - Assert.That(order.IndexOf("a.Label"), Is.LessThan(order.IndexOf("b.Label")), - "rows render in model order, so a reordered model reorders the view"); - } - - // The applier (Layer 2 -> Layer 3) is what the composer runs at CompileForObject: - // prove the patched IR the view is built from carries the visibility/order - // the menu wrote, end to end. - [Test] - public void Applier_AppliesVisibilityAndReorder_ToTheCompiledIR() - { - var shipped = new ViewDefinitionModel("LexEntry", "Normal", "detail", new[] - { - Group("g", FieldNode("g/a"), FieldNode("g/b"), FieldNode("g/c")) - }, null); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never), - new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/c", "g/b", "g/a" }) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/c", "g/b", "g/a" }), - "the reorder op reorders the children the composer walks"); - Assert.That(children.Single(c => c.StableId == "g/a").Visibility, - Is.EqualTo(ViewVisibility.Never), "the visibility op flips the node's visibility"); - } - - private static ViewNode FieldNode(string id) - => new ViewNode(id, ViewNodeKind.Field, id, null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, - false, null, null); - - private static ViewNode Group(string id, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, id, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs new file mode 100644 index 0000000000..b53cf4d0e9 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + [TestFixture] + public class DetailRenderingTests + { + private static DetailField TextField(string id, string label) + => new DetailField(id, label, label, null, DetailFieldKind.Text, + EditorClassification.Known, id, null, HostRouting.Inherit, + new List { new DetailWsValue("en", "value") }, + null, null, isEditable: true, indent: 0, objectHvo: 1234); + + private static DataTree Render(params DetailField[] fields) + { + var model = new DetailModel("LexEntry", "Normal", fields.ToList(), + new List()); + var view = new DataTree(model, null, null, null, null, null); + var window = new Window { Content = view, Width = 480, Height = 360 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return view; + } + + private static List RenderedLabelIds(DataTree view) + => view.GetVisualDescendants().OfType() + .Select(t => AutomationProperties.GetAutomationId(t)) + .Where(id => !string.IsNullOrEmpty(id) && id.EndsWith(".Label")) + .ToList(); + + [AvaloniaTest] + public void DetailView_RendersOnlyTheRowsInTheModel() + { + var view = Render(TextField("a", "Alpha"), TextField("c", "Gamma")); + + var labels = RenderedLabelIds(view); + Assert.That(labels, Has.Member("a.Label")); + Assert.That(labels, Has.Member("c.Label")); + Assert.That(labels, Has.No.Member("b.Label"), + "a row omitted from the model does not render"); + } + + [AvaloniaTest] + public void DetailView_RendersRowsInModelOrder_SoAReorderIsVisible() + { + var view = Render(TextField("c", "Gamma"), TextField("a", "Alpha"), + TextField("b", "Beta")); + + var order = RenderedLabelIds(view); + Assert.That(order.IndexOf("c.Label"), Is.LessThan(order.IndexOf("a.Label"))); + Assert.That(order.IndexOf("a.Label"), Is.LessThan(order.IndexOf("b.Label")), + "rows render in model order"); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs index 1da14eae9e..0dec1dafd6 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutChoiceResolutionTests.cs @@ -93,8 +93,6 @@ public void SelectLayoutForChoice_EmptyOrNullVariants_ReturnsNull() Assert.That(LayoutSourceLoader.SelectLayoutForChoice(null, GuidA), Is.Null); } - // Two different choiceGuids on the SAME class must yield two DISTINCT - // layouts (the selector is the cache-discriminator; the composer keys CompiledModels by choiceGuid). [Test] public void TwoChoiceGuids_OnSameKey_SelectDistinctLayouts_NoCollision() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs deleted file mode 100644 index 2011ef3afe..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Override load side: applying a sparse patch to the shipped definition reproduces the customized - /// definition, and is the inverse of the differ for representable changes. Pure logic. - /// - [TestFixture] - public class ViewDefinitionOverrideApplierTests - { - private static ViewNode FieldNode(string id, string label, - ViewVisibility vis = ViewVisibility.Always, string field = "F", string editor = "string") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, editor, - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - private static ViewDefinitionOverride Empty() - => new ViewDefinitionOverride("LexEntry", "detail", "jtview", null, null); - - [Test] - public void Apply_EmptyPatch_ReproducesBaseExactly() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, Empty()); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(shipped.ToSnapshot())); - } - - // Every node is rebuilt on apply, so a clone that omits a field strips it tree-wide once - // any override exists. These three fields are outside ToSnapshot() and aren't covered by - // the EmptyPatch test. - [Test] - public void Apply_PreservesNodeFieldsNoOperationTouches() - { - var writingSystems = new[] { "fr", "seh" }; - var options = new ViewStringList(new[] { "IsElsewhereForm", "IsAbstractForm" }, "AllomorphStatus"); - var shipped = Model(GroupNode("g", "Group", - new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "multistring", - EditorClassification.Known, "vern", ViewVisibility.Always, - ViewExpansion.NotApplicable, false, null, null, - enumStringList: options, visibleWritingSystems: writingSystems, toggleValue: true))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var rebuilt = applied.Roots[0].Children[0]; - Assert.That(rebuilt.Visibility, Is.EqualTo(ViewVisibility.Never), "the operation still applies"); - Assert.That(rebuilt.VisibleWritingSystems, Is.EqualTo(writingSystems), - "a per-field writing-system subset survives the rebuild"); - Assert.That(rebuilt.ToggleValue, Is.True, "a toggle value survives the rebuild"); - Assert.That(rebuilt.EnumStringList?.Ids, Is.EqualTo(options.Ids), - "an enum option list survives the rebuild"); - } - - [Test] - public void Apply_AddNode_InsertsAtParentIndex() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/new", - label: "New", parentStableId: "g", index: 1, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always) - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a", "g/new" })); - Assert.That(children[1].Label, Is.EqualTo("New")); - } - - [Test] - public void Apply_DuplicateNode_CopiesLeafUnderNewId() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy", - parentStableId: "g", index: 1, sourceStableId: "g/a") - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - var children = applied.Roots[0].Children; - Assert.That(children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a", "g/a-copy" })); - Assert.That(children[1].Label, Is.EqualTo("A"), "the duplicate copies the source's content"); - Assert.That(children[1].Field, Is.EqualTo("F")); - } - - [Test] - public void Apply_DuplicateNode_SourceWithChildren_ReportsDiagnostic_AndDoesNotInsert() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - // Try to duplicate the group 'g' (which has a child) under the root -- not yet - // supported. - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g-copy", - parentStableId: null, index: 1, sourceStableId: "g") - }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "duplicate-with-children-unsupported"), Is.True); - Assert.That(applied.Roots.Select(r => r.StableId), Is.EqualTo(new[] { "g" }), "the unsupported duplicate is not inserted"); - } - - [Test] - public void Apply_StalePatchTarget_IsReportedAsDiagnostic_NotFatal() - { - var shipped = Model(FieldNode("a", "A")); - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] { new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "ghost", - visibility: ViewVisibility.Never) }, null); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.True); - // The real node is untouched. - Assert.That(applied.Roots.Single().Visibility, Is.EqualTo(ViewVisibility.Always)); - } - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_VisibilityLabelHide() - { - var shipped = Model(GroupNode("g", "Group", - FieldNode("g/a", "A"), FieldNode("g/b", "B"), FieldNode("g/c", "C"))); - // Customer: relabel + hide one + change visibility -- all representable, all fully - // captured. - var customized = Model(GroupNode("g", "Group", - FieldNode("g/a", "Headword", ViewVisibility.Never), FieldNode("g/c", "C"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - } - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_Reorder() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/b", "B"), FieldNode("g/a", "A"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs deleted file mode 100644 index 6f67906f56..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Diffing a shipped definition against a project-customized copy yields a sparse, stable-id-keyed - /// override; non-representable customizations surface as diagnostics, never silent drops. Pure - /// logic -- no Avalonia runtime. - /// - [TestFixture] - public class ViewDefinitionOverrideDifferTests - { - private static ViewNode FieldNode(string id, string label, - ViewVisibility vis = ViewVisibility.Always, string field = "F", string editor = "string") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, editor, - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - [Test] - public void Diff_IdenticalDefinitions_IsEmpty() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.IsEmpty, Is.True); - Assert.That(diff.Operations, Is.Empty); - Assert.That(diff.Diagnostics, Is.Empty); - Assert.That(diff.FormatVersion, Is.EqualTo(ViewDefinitionOverride.CurrentFormatVersion)); - } - - [Test] - public void Diff_VisibilityChange_EmitsSetVisibility() - { - var shipped = Model(FieldNode("a", "A", ViewVisibility.Always)); - var overridden = Model(FieldNode("a", "A", ViewVisibility.Never)); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - var op = diff.Operations[0]; - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("a")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - Assert.That(diff.Diagnostics, Is.Empty); - } - - [Test] - public void Diff_LabelChange_EmitsSetLabel() - { - var shipped = Model(FieldNode("a", "Lexeme Form")); - var overridden = Model(FieldNode("a", "Headword")); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - Assert.That(diff.Operations[0].Kind, Is.EqualTo(ViewOverrideOperationKind.SetLabel)); - Assert.That(diff.Operations[0].Label, Is.EqualTo("Headword")); - } - - [Test] - public void Diff_ChildReorder_EmitsReorderChildren_WithNewOrder() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/b", "B"), FieldNode("g/a", "A"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var reorder = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.ReorderChildren); - Assert.That(reorder.StableId, Is.EqualTo("g")); - Assert.That(reorder.ChildOrder, Is.EqualTo(new[] { "g/b", "g/a" })); - // The children themselves are unchanged, so they must not generate spurious ops. - Assert.That(diff.Operations.Count, Is.EqualTo(1)); - } - - [Test] - public void Diff_NodeOnlyInOverride_EmitsAddNode_WithParentAndIndex() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/custom", "Custom"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var add = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.AddNode); - Assert.That(add.StableId, Is.EqualTo("g/custom")); - Assert.That(add.ParentStableId, Is.EqualTo("g"), "the added node records its parent"); - Assert.That(add.Index, Is.EqualTo(1), "the added node records its insertion index among siblings"); - Assert.That(add.NodeKind, Is.EqualTo(ViewNodeKind.Field)); - Assert.That(add.Label, Is.EqualTo("Custom")); - // A customer addition is representable, not a lossy diagnostic. - Assert.That(diff.Diagnostics.Any(d => d.Code == "override-added-node"), Is.False); - } - - [Test] - public void Diff_NodeOnlyInShipped_EmitsHideNode() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var overridden = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var hide = diff.Operations.Single(o => o.Kind == ViewOverrideOperationKind.HideNode); - Assert.That(hide.StableId, Is.EqualTo("g/b")); - } - - [Test] - public void Diff_BindingOrEditorChange_IsReportedUnrepresentable_NotSilentlyPatched() - { - // Same StableId, but the override changed the editor AND the label. The editor change is not a - // representable sparse patch, so the whole node is reported and NO label op is emitted for it. - var shipped = Model(FieldNode("a", "A", editor: "string")); - var overridden = Model(FieldNode("a", "A-renamed", editor: "integer")); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - Assert.That(diff.Operations, Is.Empty, "an unrepresentable change must not produce a (wrong) sparse patch"); - var diag = diff.Diagnostics.Single(d => d.Code == "override-unrepresentable-change"); - Assert.That(diag.NodePath, Is.EqualTo("a")); - Assert.That(diag.Severity, Is.EqualTo(ViewDiagnosticSeverity.Warning)); - } - - [Test] - public void Diff_Operations_AreDeterministicallyOrderedByStableId() - { - var shipped = Model( - FieldNode("zeta", "Z", ViewVisibility.Always), - FieldNode("alpha", "A", ViewVisibility.Always)); - var overridden = Model( - FieldNode("zeta", "Z", ViewVisibility.Never), - FieldNode("alpha", "A", ViewVisibility.Never)); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - - var ids = diff.Operations.Select(o => o.StableId).ToList(); - Assert.That(ids, Is.EqualTo(new[] { "alpha", "zeta" }), "operations must be ordered by StableId"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs deleted file mode 100644 index f307cc21f9..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.IO; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Boundary/edge-case hardening for the override pipeline: malformed-JSON enum handling (controlled - /// InvalidDataException, never a raw NRE/ArgumentException), id-collision rejection on insert ops, - /// the AddNode round-trip the existing suite lacked, and AddNode index clamping. - /// - [TestFixture] - public class ViewDefinitionOverrideEdgeCaseTests - { - private static ViewNode FieldNode(string id, string label, string field = "F") - => new ViewNode(id, ViewNodeKind.Field, label, null, field, "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode GroupNode(string id, string label, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, label, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "detail", "jtview", roots, null); - - private static ViewDefinitionOverride Patch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "detail", "jtview", ops, null); - - // ----- malformed-JSON enum handling ----- - - [Test] - public void Deserialize_GarbageVisibility_ThrowsControlledInvalidData() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never))); - var bad = json.Replace("\"Never\"", "\"Bogus\""); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf(), "an unknown enum token is a controlled data error"); - } - - [Test] - public void Deserialize_NullVisibility_ThrowsControlledInvalidData_NotNullRef() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never))); - var bad = json.Replace("\"Never\"", "null"); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf(), "a null enum token must not throw a raw NullReferenceException"); - } - - [Test] - public void Deserialize_UnknownOpKind_ThrowsInvalidData() - { - var json = ViewDefinitionOverrideJsonSerializer.Serialize( - Patch(new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, "a", label: "X"))); - // Replace the wire op name (whatever it is) with a bogus one. - var bad = System.Text.RegularExpressions.Regex.Replace(json, "\"op\"\\s*:\\s*\"[^\"]+\"", "\"op\": \"frobnicate\""); - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(bad), - Throws.InstanceOf()); - } - - // ----- id-collision rejection on insert ops ----- - - [Test] - public void Apply_AddNode_CollidingId_IsRejectedWithDiagnostic_NotInserted() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/a", - label: "Dup", parentStableId: "g", index: 1, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always)); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-duplicate-id"), Is.True); - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(new[] { "g/a" }), - "a colliding addNode id is not inserted, preserving id uniqueness"); - } - - [Test] - public void Apply_DuplicateNode_CollidingId_IsRejectedWithDiagnostic() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a", - parentStableId: "g", index: 1, sourceStableId: "g/a")); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-duplicate-id"), Is.True); - Assert.That(applied.Roots[0].Children.Count, Is.EqualTo(1)); - } - - // ----- AddNode round-trip (was missing) + index clamping ----- - - [Test] - public void RoundTrip_DiffThenApply_ReproducesCustomized_WithAddedNode() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/new", "New", "NewField"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot()), - "the add-node round-trip reproduces the customized model exactly"); - } - - [TestCase(0, new[] { "g/new", "g/a", "g/b" })] - [TestCase(1, new[] { "g/a", "g/new", "g/b" })] - [TestCase(99, new[] { "g/a", "g/b", "g/new" })] // clamped to count - [TestCase(-5, new[] { "g/new", "g/a", "g/b" })] // clamped to 0 - public void Apply_AddNode_IndexIsClampedToBounds(int index, string[] expectedOrder) - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/b", "B"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/new", - label: "New", parentStableId: "g", index: index, nodeKind: ViewNodeKind.Field, - field: "F", editor: "string", visibility: ViewVisibility.Always)); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(expectedOrder)); - } - - [Test] - public void RoundTrip_AddedNode_SurvivesTheJsonWireLane_IncludingWritingSystem() - { - var shipped = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var customized = Model(GroupNode("g", "Group", FieldNode("g/a", "A"), FieldNode("g/new", "New", "NewField"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var reloaded = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, reloaded); - - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot()), - "the added node (with its writing system) survives serialize → deserialize → apply"); - } - - [Test] - public void RoundTrip_RootLevelReorder_IsReproduced() - { - // Reordering the top-level fields is a common customization; it must round-trip, not be dropped. - var shipped = Model(FieldNode("r1", "R1"), FieldNode("r2", "R2"), FieldNode("r3", "R3")); - var customized = Model(FieldNode("r3", "R3"), FieldNode("r1", "R1"), FieldNode("r2", "R2")); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots.Select(r => r.StableId), Is.EqualTo(new[] { "r3", "r1", "r2" })); - Assert.That(applied.ToSnapshot(), Is.EqualTo(customized.ToSnapshot())); - Assert.That(applied.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.False, - "the root-level reorder op is not falsely reported as a stale target"); - } - - [Test] - public void Reparent_IsReportedAsDiagnostic_NotSilentlyDropped() - { - // Moving a node to a different parent is not representable as a sparse patch -- but - // it must be - // reported, never silently lost. - var shipped = Model(GroupNode("g1", "G1", FieldNode("a", "A")), GroupNode("g2", "G2")); - var customized = Model(GroupNode("g1", "G1"), GroupNode("g2", "G2", FieldNode("a", "A"))); - - var patch = ViewDefinitionOverrideDiffer.Diff(shipped, customized); - - Assert.That(patch.Diagnostics.Any(d => d.Code == "override-reparent-unrepresentable"), Is.True, - "a reparented node is reported, not dropped"); - } - - [Test] - public void Diff_IdenticalModels_ProducesEmptyPatch() - { - var model = Model(GroupNode("g", "Group", FieldNode("g/a", "A"))); - var patch = ViewDefinitionOverrideDiffer.Diff(model, model); - Assert.That(patch.Operations, Is.Empty); - } - - [Test] - public void Apply_ReorderChildren_PartialOrder_KeepsUnnamedAtEnd() - { - var shipped = Model(GroupNode("g", "Group", - FieldNode("g/a", "A"), FieldNode("g/b", "B"), FieldNode("g/c", "C"))); - var patch = Patch(new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/c" })); - - var applied = ViewDefinitionOverrideApplier.Apply(shipped, patch); - - Assert.That(applied.Roots[0].Children.Select(c => c.StableId), Is.EqualTo(new[] { "g/c", "g/a", "g/b" }), - "named ids move first; the rest keep their original relative order"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs deleted file mode 100644 index c05f87433f..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// advanced-entry-view: the pure "where does the per-field gear-menu command land" logic -- - /// strip the - /// runtime hvo suffix, locate a node's parent + sibling order + visibility in a compiled definition, - /// compute the moved sibling order, and fold one operation into an existing override (idempotently). - /// No XCore/LCModel. - /// - [TestFixture] - public class ViewDefinitionOverrideEditorTests - { - private static ViewNode Field(string id, ViewVisibility vis = ViewVisibility.Always) - => new ViewNode(id, ViewNodeKind.Field, id, null, "F", "string", - EditorClassification.Known, "vern", vis, ViewExpansion.NotApplicable, false, null, null); - - private static ViewNode Group(string id, params ViewNode[] children) - => new ViewNode(id, ViewNodeKind.Group, id, null, null, null, - EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.Expanded, - false, null, children); - - private static ViewDefinitionModel Model(params ViewNode[] roots) - => new ViewDefinitionModel("LexEntry", "Normal", "detail", roots, null); - - [TestCase("/#0/#1@1234", "/#0/#1")] - [TestCase("/#0/#1@1234/item3", "/#0/#1/item3")] - [TestCase("/#0/#1@1234/pic0", "/#0/#1/pic0")] - [TestCase("/#0/#1", "/#0/#1")] // already a template id (no hvo) - [TestCase("", "")] - [TestCase(null, null)] - public void StripRuntimeSuffix_RemovesHvo_KeepsTrailingPath(string runtime, string expected) - { - Assert.That(ViewDefinitionOverrideEditor.StripRuntimeSuffix(runtime), Is.EqualTo(expected)); - } - - [Test] - public void LocateTarget_ReturnsParentSiblingOrderIndexAndVisibility() - { - var model = Model(Group("g", Field("g/a"), Field("g/b", ViewVisibility.IfData), Field("g/c"))); - - var loc = ViewDefinitionOverrideEditor.LocateTarget(model, "g/b"); - - Assert.That(loc, Is.Not.Null); - Assert.That(loc.ParentStableId, Is.EqualTo("g")); - Assert.That(loc.SiblingOrder, Is.EqualTo(new[] { "g/a", "g/b", "g/c" })); - Assert.That(loc.Index, Is.EqualTo(1)); - Assert.That(loc.Visibility, Is.EqualTo(ViewVisibility.IfData)); - Assert.That(loc.CanMoveUp, Is.True); - Assert.That(loc.CanMoveDown, Is.True); - } - - [Test] - public void LocateTarget_RootLevelNode_HasNullParent() - { - var model = Model(Field("r0"), Field("r1")); - - var loc = ViewDefinitionOverrideEditor.LocateTarget(model, "r0"); - - Assert.That(loc.ParentStableId, Is.Null); - Assert.That(loc.Index, Is.EqualTo(0)); - Assert.That(loc.CanMoveUp, Is.False, "the first root node cannot move up"); - Assert.That(loc.CanMoveDown, Is.True); - } - - [Test] - public void LocateTarget_UnknownId_ReturnsNull() - { - var model = Model(Group("g", Field("g/a"))); - Assert.That(ViewDefinitionOverrideEditor.LocateTarget(model, "nope"), Is.Null); - } - - [Test] - public void ComputeMovedOrder_Up_SwapsWithPrevious() - { - var order = new[] { "a", "b", "c" }; - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 2, up: true); - Assert.That(moved, Is.EqualTo(new[] { "a", "c", "b" })); - } - - [Test] - public void ComputeMovedOrder_Down_SwapsWithNext() - { - var order = new[] { "a", "b", "c" }; - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 0, up: false); - Assert.That(moved, Is.EqualTo(new[] { "b", "a", "c" })); - } - - [Test] - public void ComputeMovedOrder_FirstUp_LastDown_OnlyChild_AreNull() - { - var order = new[] { "a", "b" }; - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 0, up: true), Is.Null, - "the first sibling cannot move up"); - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(order, 1, up: false), Is.Null, - "the last sibling cannot move down"); - Assert.That(ViewDefinitionOverrideEditor.ComputeMovedOrder(new[] { "solo" }, 0, up: true), Is.Null, - "a single child cannot move"); - } - - [Test] - public void MergeOperation_AppendsNewTarget() - { - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", null, null); - var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, op); - - Assert.That(merged.Operations.Count, Is.EqualTo(1)); - Assert.That(merged.Operations[0].StableId, Is.EqualTo("g/a")); - } - - [Test] - public void MergeOperation_ReplacesSameKindAndTarget_KeepsOthers() - { - var first = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Never); - var unrelated = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/b", - visibility: ViewVisibility.IfData); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", - new[] { first, unrelated }, null); - var replacement = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g/a", - visibility: ViewVisibility.Always); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, replacement); - - Assert.That(merged.Operations.Count, Is.EqualTo(2), "the same target+kind is replaced, not duplicated"); - var aOp = merged.Operations.Single(o => o.StableId == "g/a"); - Assert.That(aOp.Visibility, Is.EqualTo(ViewVisibility.Always)); - Assert.That(merged.Operations.Single(o => o.StableId == "g/b").Visibility, - Is.EqualTo(ViewVisibility.IfData), "an unrelated op is preserved"); - } - - [Test] - public void MergeOperation_DifferentKindSameTarget_BothKept() - { - var vis = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "g", - visibility: ViewVisibility.Never); - var reorder = new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/b", "g/a" }); - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", new[] { vis }, null); - - var merged = ViewDefinitionOverrideEditor.MergeOperation(patch, reorder); - - Assert.That(merged.Operations.Count, Is.EqualTo(2), - "a reorder on the same id as a visibility op is a different concern and is kept"); - } - - [Test] - public void MergeOperation_DoesNotMutateInput() - { - var patch = new ViewDefinitionOverride("LexEntry", "Normal", "detail", null, null); - ViewDefinitionOverrideEditor.MergeOperation(patch, - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "x", - visibility: ViewVisibility.Never)); - Assert.That(patch.Operations.Count, Is.EqualTo(0), "the source override is never mutated"); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs deleted file mode 100644 index 0a2e7895bf..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// Project-file side: reads a whole-copy .fwlayout override from disk, diffs it against the - /// shipped layout, and writes the canonical JSON patch -- verified with temp files and inline - /// XML - /// (no XCore/Inventory). - /// - [TestFixture] - public class ViewDefinitionOverrideFileMigratorTests - { - private const string PartsXml = @" - - - - - - - -"; - - private const string ShippedLayout = @" - - - -"; - - private const string OverrideLayout = @" - - - -"; - - private static IPartResolver Parts() => new DictionaryPartResolver(XElement.Parse(PartsXml)); - - private string _overrideFile; - private string _outputFile; - - [SetUp] - public void SetUp() - { - _overrideFile = Path.Combine(Path.GetTempPath(), "fwlayout-" + Guid.NewGuid().ToString("N") + ".fwlayout"); - _outputFile = Path.Combine(Path.GetTempPath(), "patch-" + Guid.NewGuid().ToString("N") + ".json"); - } - - [TearDown] - public void TearDown() - { - if (File.Exists(_overrideFile)) File.Delete(_overrideFile); - if (File.Exists(_outputFile)) File.Delete(_outputFile); - } - - [Test] - public void MigrateOverrideFile_ReadsOverride_ReturnsPatch_AndWritesJsonFile() - { - File.WriteAllText(_overrideFile, OverrideLayout); - - var patch = ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts(), _outputFile); - - // Returned patch captures the customer's edit. - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - - // And the canonical JSON patch file was written and round-trips. - Assert.That(File.Exists(_outputFile), Is.True); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(_outputFile)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - - [Test] - public void MigrateOverrideFile_NoCustomization_WritesEmptyPatch() - { - File.WriteAllText(_overrideFile, ShippedLayout); - - var patch = ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts(), _outputFile); - - Assert.That(patch.IsEmpty, Is.True); - Assert.That(File.Exists(_outputFile), Is.True, "an empty patch is still written (records that the layout was reconciled)"); - } - - [Test] - public void MigrateOverrideFile_MissingFile_Throws() - { - Assert.That(() => ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - XElement.Parse(ShippedLayout), _overrideFile, Parts()), - Throws.InstanceOf()); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs deleted file mode 100644 index edf6b48a0c..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// The per-project override patch serializes to deterministic canonical JSON and round-trips - /// losslessly, including its audit diagnostics. Pure logic -- no Avalonia runtime. - /// - [TestFixture] - public class ViewDefinitionOverrideJsonSerializerTests - { - private static ViewDefinitionOverride SampleWithAllOpKinds() - { - var ops = new List - { - new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, "a", visibility: ViewVisibility.Never), - new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, "b", label: "Headword"), - new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, "g", - childOrder: new[] { "g/b", "g/a" }), - new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, "c") - }; - var diags = new List - { - new ViewDiagnostic(ViewDiagnosticSeverity.Info, "override-added-node", "customer-added", "g/x") - }; - return new ViewDefinitionOverride("LexEntry", "detail", "jtview", ops, diags); - } - - [Test] - public void RoundTrip_PreservesHeaderOperationsAndDiagnostics() - { - var original = SampleWithAllOpKinds(); - - var json = ViewDefinitionOverrideJsonSerializer.Serialize(original); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(json); - - Assert.That(restored.FormatVersion, Is.EqualTo(original.FormatVersion)); - Assert.That(restored.ClassName, Is.EqualTo("LexEntry")); - Assert.That(restored.LayoutName, Is.EqualTo("detail")); - Assert.That(restored.LayoutType, Is.EqualTo("jtview")); - - Assert.That(restored.Operations.Count, Is.EqualTo(4)); - - var vis = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility); - Assert.That(vis.StableId, Is.EqualTo("a")); - Assert.That(vis.Visibility, Is.EqualTo(ViewVisibility.Never)); - - var label = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetLabel); - Assert.That(label.StableId, Is.EqualTo("b")); - Assert.That(label.Label, Is.EqualTo("Headword")); - - var reorder = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.ReorderChildren); - Assert.That(reorder.StableId, Is.EqualTo("g")); - Assert.That(reorder.ChildOrder, Is.EqualTo(new[] { "g/b", "g/a" })); - - var hide = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.HideNode); - Assert.That(hide.StableId, Is.EqualTo("c")); - - Assert.That(restored.Diagnostics.Count, Is.EqualTo(1)); - Assert.That(restored.Diagnostics[0].Code, Is.EqualTo("override-added-node")); - Assert.That(restored.Diagnostics[0].Severity, Is.EqualTo(ViewDiagnosticSeverity.Info)); - Assert.That(restored.Diagnostics[0].NodePath, Is.EqualTo("g/x")); - } - - [Test] - public void Serialize_IsDeterministic() - { - var patch = SampleWithAllOpKinds(); - Assert.That(ViewDefinitionOverrideJsonSerializer.Serialize(patch), - Is.EqualTo(ViewDefinitionOverrideJsonSerializer.Serialize(patch))); - } - - [Test] - public void Serialize_OmitsDiagnostics_WhenThereAreNone() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] { new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, "c") }, - diagnostics: null); - - var json = ViewDefinitionOverrideJsonSerializer.Serialize(patch); - - Assert.That(json, Does.Not.Contain("diagnostics"), - "a clean override must not carry an empty diagnostics array"); - } - - [Test] - public void Deserialize_WrongFormatVersion_Throws() - { - const string json = "{ \"formatVersion\": 99, \"class\": \"LexEntry\", \"operations\": [] }"; - Assert.That(() => ViewDefinitionOverrideJsonSerializer.Deserialize(json), - Throws.TypeOf()); - } - - [Test] - public void RoundTrip_PreservesAddNode_WithParentIndexAndIdentity() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, "g/custom", - visibility: ViewVisibility.IfData, label: "Custom", - parentStableId: "g", index: 2, nodeKind: ViewNodeKind.Field, - field: "Custom", editor: "string") - }, - diagnostics: null); - - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - - var add = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.AddNode); - Assert.That(add.StableId, Is.EqualTo("g/custom")); - Assert.That(add.ParentStableId, Is.EqualTo("g")); - Assert.That(add.Index, Is.EqualTo(2)); - Assert.That(add.NodeKind, Is.EqualTo(ViewNodeKind.Field)); - Assert.That(add.Label, Is.EqualTo("Custom")); - Assert.That(add.Field, Is.EqualTo("Custom")); - Assert.That(add.Editor, Is.EqualTo("string")); - Assert.That(add.Visibility, Is.EqualTo(ViewVisibility.IfData)); - } - - [Test] - public void RoundTrip_PreservesDuplicateNode() - { - var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", - new[] - { - new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy", - parentStableId: "g", index: 1, sourceStableId: "g/a") - }, null); - - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - - var dup = restored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.DuplicateNode); - Assert.That(dup.StableId, Is.EqualTo("g/a-copy")); - Assert.That(dup.SourceStableId, Is.EqualTo("g/a")); - Assert.That(dup.ParentStableId, Is.EqualTo("g")); - Assert.That(dup.Index, Is.EqualTo(1)); - } - - [Test] - public void DiffThenSerialize_RoundTrips() - { - var shipped = new ViewDefinitionModel("LexEntry", "detail", "jtview", - new[] - { - new ViewNode("a", ViewNodeKind.Field, "A", null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Always, ViewExpansion.NotApplicable, - false, null, null) - }, null); - var overridden = new ViewDefinitionModel("LexEntry", "detail", "jtview", - new[] - { - new ViewNode("a", ViewNodeKind.Field, "A", null, "F", "string", - EditorClassification.Known, "vern", ViewVisibility.Never, ViewExpansion.NotApplicable, - false, null, null) - }, null); - - var diff = ViewDefinitionOverrideDiffer.Diff(shipped, overridden); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize( - ViewDefinitionOverrideJsonSerializer.Serialize(diff)); - - Assert.That(restored.Operations.Count, Is.EqualTo(1)); - Assert.That(restored.Operations[0].Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(restored.Operations[0].Visibility, Is.EqualTo(ViewVisibility.Never)); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs deleted file mode 100644 index 5612610aee..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// A legacy whole-copy .fwlayout override imports + diffs into a sparse patch capturing - /// exactly the customer's edits. Reuses the real over inline - /// XML -- - /// no XCore/file I/O. - /// - [TestFixture] - public class ViewDefinitionOverrideMigratorTests - { - private const string PartsXml = @" - - - - - - - -"; - - private static IPartResolver Parts() => new DictionaryPartResolver(XElement.Parse(PartsXml)); - - private const string ShippedLayout = @" - - - -"; - - [Test] - public void MigrateLayout_NoCustomization_ProducesEmptyPatch() - { - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(ShippedLayout, ShippedLayout, Parts()); - Assert.That(patch.IsEmpty, Is.True); - } - - [Test] - public void MigrateLayout_VisibilityCustomization_ProducesSetVisibilityPatch() - { - // The project hid Bibliography (ifdata -> never), the legacy whole-copy override. - const string overridden = @" - - - -"; - - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(ShippedLayout, overridden, Parts()); - - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1"), "Bibliography is the second root part"); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - } - - [Test] - public void MigrateLayoutToJson_RoundTripsToTheSamePatch() - { - const string overridden = @" - - - -"; - - var json = ViewDefinitionOverrideMigrator.MigrateLayoutToJson( - XElement.Parse(ShippedLayout), XElement.Parse(overridden), Parts()); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(json); - - Assert.That(restored.Operations.Single().Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - } -} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs deleted file mode 100644 index 75d860a917..0000000000 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace FwAvaloniaTests -{ - /// - /// The per-project override store round-trips a patch through the ConfigurationSettings-folder JSON - /// file (one per class+layout), loads lazily, caches per key, and treats an empty patch as "delete - /// the file" (undo-to-base leaves no stale override). Corrupt or mislabeled files degrade to "no - /// override" rather than crashing compose. - /// - [TestFixture] - public class ViewDefinitionOverrideStoreTests - { - private string _dir; - - [SetUp] - public void SetUp() - { - _dir = Path.Combine(Path.GetTempPath(), "viewoverride-store-" + Guid.NewGuid().ToString("N")); - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_dir)) - Directory.Delete(_dir, recursive: true); - } - - private static ViewDefinitionOverride Patch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "Normal", "detail", ops, null); - - private static ViewOverrideOperation Vis(string id, ViewVisibility vis) - => new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, id, visibility: vis); - - [Test] - public void Save_ThenTryGet_RoundTripsThroughDisk() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - - // A fresh store (no in-memory cache) must read the same patch back from the file. - var reloaded = new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"); - - Assert.That(reloaded, Is.Not.Null); - Assert.That(reloaded.Operations.Count, Is.EqualTo(1)); - Assert.That(reloaded.Operations[0].StableId, Is.EqualTo("/#0")); - Assert.That(reloaded.Operations[0].Visibility, Is.EqualTo(ViewVisibility.Never)); - } - - [Test] - public void TryGet_NoFile_ReturnsNull() - { - Assert.That(new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void Save_WritesToPredictablePerClassLayoutFile() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Always))); - - var expected = Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"); - Assert.That(File.Exists(expected), Is.True); - Assert.That(store.PathFor("LexEntry", "Normal"), Is.EqualTo(expected)); - } - - [Test] - public void Save_EmptyPatch_DeletesTheFile() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - Assert.That(File.Exists(store.PathFor("LexEntry", "Normal")), Is.True); - - store.Save(Patch()); // emptied — the project no longer customizes this layout - - Assert.That(File.Exists(store.PathFor("LexEntry", "Normal")), Is.False, - "an empty override deletes the file so the loader sees the shipped definition"); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void TryGet_DistinctKeys_AreIsolated() - { - var store = new ViewDefinitionOverrideStore(_dir); - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - store.Save(new ViewDefinitionOverride("LexSense", "Normal", "detail", - new[] { Vis("/#1", ViewVisibility.IfData) }, null)); - - Assert.That(store.TryGet("LexEntry", "Normal").Operations[0].StableId, Is.EqualTo("/#0")); - Assert.That(store.TryGet("LexSense", "Normal").Operations[0].StableId, Is.EqualTo("/#1")); - Assert.That(store.TryGet("LexSense", "Other"), Is.Null); - } - - [Test] - public void TryGet_CorruptFile_ReportsErrorAndReturnsNull() - { - Directory.CreateDirectory(_dir); - File.WriteAllText(Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"), "{ not valid json"); - var store = new ViewDefinitionOverrideStore(_dir); - - Exception captured = null; - var result = store.TryGet("LexEntry", "Normal", (path, e) => captured = e); - - Assert.That(result, Is.Null, "a corrupt file degrades to no-override, never a crash"); - Assert.That(captured, Is.Not.Null, "the load failure is surfaced to the caller for logging"); - } - - [Test] - public void TryGet_HeaderMismatch_IsIgnored() - { - // A file whose JSON header disagrees with the requested key (renamed/hand-edited) is not used. - Directory.CreateDirectory(_dir); - var foreignPatch = new ViewDefinitionOverride("LexSense", "Normal", "detail", - new[] { Vis("/#0", ViewVisibility.Never) }, null); - File.WriteAllText(Path.Combine(_dir, "LexEntry.Normal.viewoverride.json"), - ViewDefinitionOverrideJsonSerializer.Serialize(foreignPatch)); - - Assert.That(new ViewDefinitionOverrideStore(_dir).TryGet("LexEntry", "Normal"), Is.Null); - } - - [Test] - public void TryGet_CachesAcrossCalls_AndSaveRefreshesCache() - { - var store = new ViewDefinitionOverrideStore(_dir); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Null); - - // Save updates the in-memory cache, so the next TryGet returns the new patch without re-reading. - store.Save(Patch(Vis("/#0", ViewVisibility.Never))); - Assert.That(store.TryGet("LexEntry", "Normal"), Is.Not.Null); - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index dda5313757..bd94c23ec7 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -7,9 +7,77 @@ using System.Collections.ObjectModel; using System.Linq; using System.Text; +using System.Xml; +using System.Xml.Linq; namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition { + /// + /// Provides a canonical identity for an element in an effective legacy layout. The identity + /// lets independently cloned XML representations recognize the same layout caller. + /// + public static class LegacyLayoutCallerPath + { + /// + /// Returns the caller's canonical layout-relative identity. Returns null when the caller + /// is null or does not belong to a layout. + /// + public static string Get(XElement caller) + { + if (caller == null) + return null; + var path = new Stack(); + var current = caller; + while (current.Parent != null && current.Parent.Name.LocalName != "layout") + { + path.Push(Segment(current.Name.LocalName, + current.ElementsBeforeSelf().Count(element => + element.Name.LocalName == current.Name.LocalName))); + current = current.Parent; + } + if (current.Parent == null || current.Parent.Name.LocalName != "layout") + return null; + path.Push(Segment(current.Name.LocalName, + current.ElementsBeforeSelf().Count(element => + element.Name.LocalName == current.Name.LocalName))); + return string.Join("/", path); + } + + /// + /// Returns the caller's canonical layout-relative identity. Returns null when the caller + /// is null or does not belong to a layout. + /// + public static string Get(XmlNode caller) + { + if (caller == null) + return null; + var path = new Stack(); + var current = caller; + while (current.ParentNode != null && current.ParentNode.LocalName != "layout") + { + path.Push(Segment(current.LocalName, SameNamePredecessorCount(current))); + current = current.ParentNode; + } + if (current.ParentNode == null || current.ParentNode.LocalName != "layout") + return null; + path.Push(Segment(current.LocalName, SameNamePredecessorCount(current))); + return string.Join("/", path); + } + + private static int SameNamePredecessorCount(XmlNode node) + { + var count = 0; + for (var sibling = node.PreviousSibling; sibling != null; sibling = sibling.PreviousSibling) + { + if (sibling.NodeType == XmlNodeType.Element && sibling.LocalName == node.LocalName) + count++; + } + return count; + } + + private static string Segment(string name, int ordinal) => $"{name}[{ordinal}]"; + } + /// /// Structural kind of a typed view-definition node. Mirrors the node types produced by the /// legacy XML Parts/Layout interpretation in SliceFactory/DataTree: @@ -385,7 +453,8 @@ public ViewNode( IReadOnlyList chooserLinks = null, ViewStringList enumStringList = null, IReadOnlyList visibleWritingSystems = null, - bool toggleValue = false) + bool toggleValue = false, + string sourceCallerPath = null) { ToggleValue = toggleValue; VisibleWritingSystems = visibleWritingSystems; @@ -421,11 +490,16 @@ public ViewNode( GhostInitMethod = ghostInitMethod; Condition = condition; ChooserLinks = chooserLinks ?? (IReadOnlyList)Array.Empty(); + SourceCallerPath = sourceCallerPath; } /// Deterministic identity derived from the node's path (stable across realizations). public string StableId { get; } + /// The structural address of the owning caller part in the effective legacy + /// layout. + public string SourceCallerPath { get; } + public ViewNodeKind Kind { get; } public string Label { get; } diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs deleted file mode 100644 index 3e3174fd66..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ /dev/null @@ -1,312 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Applies a sparse to a shipped - /// to produce the project-customized model. The inverse of : - /// for representable customizations, Apply(base, Diff(base, custom)) reproduces custom. Pure logic over the - /// immutable IR -- no XCore/Inventory or live cache. - /// - /// Patches that reference a StableId no longer present in the shipped base are reported as diagnostics on - /// the result rather than throwing (stale patches are quarantined per-operation, not fatal). - /// - public static class ViewDefinitionOverrideApplier - { - private const string RootParentKey = ""; // normalized key for a null (root-level) parent - - public static ViewDefinitionModel Apply(ViewDefinitionModel shipped, ViewDefinitionOverride patch) - { - if (shipped == null) throw new ArgumentNullException(nameof(shipped)); - if (patch == null) throw new ArgumentNullException(nameof(patch)); - - var setVisibility = new Dictionary(StringComparer.Ordinal); - var setLabel = new Dictionary(StringComparer.Ordinal); - var hide = new HashSet(StringComparer.Ordinal); - var reorder = new Dictionary>(StringComparer.Ordinal); - var addByParent = new Dictionary>(StringComparer.Ordinal); - var duplicateByParent = new Dictionary>(StringComparer.Ordinal); - - foreach (var op in patch.Operations) - { - switch (op.Kind) - { - case ViewOverrideOperationKind.SetVisibility: - if (op.Visibility.HasValue) setVisibility[op.StableId] = op.Visibility.Value; - break; - case ViewOverrideOperationKind.SetLabel: - setLabel[op.StableId] = op.Label; - break; - case ViewOverrideOperationKind.HideNode: - hide.Add(op.StableId); - break; - case ViewOverrideOperationKind.ReorderChildren: - reorder[op.StableId] = op.ChildOrder; - break; - case ViewOverrideOperationKind.AddNode: - AppendByParent(addByParent, op); - break; - case ViewOverrideOperationKind.DuplicateNode: - AppendByParent(duplicateByParent, op); - break; - } - } - - SortByIndexThenId(addByParent); - SortByIndexThenId(duplicateByParent); - - var diagnostics = new List(shipped.Diagnostics); - var baseById = FlattenBase(shipped.Roots); - var context = new ApplyContext( - setVisibility, setLabel, hide, reorder, addByParent, duplicateByParent, baseById, diagnostics); - - var newRoots = context.RebuildChildren(RootParentKey, shipped.Roots); - - // Report patch operations whose target/parent StableId no longer exists (stale patch), per-op. - context.ReportUnresolved(patch); - - return new ViewDefinitionModel( - shipped.ClassName, shipped.LayoutName, shipped.LayoutType, newRoots, diagnostics); - } - - private sealed class ApplyContext - { - private readonly Dictionary _setVisibility; - private readonly Dictionary _setLabel; - private readonly HashSet _hide; - private readonly Dictionary> _reorder; - private readonly Dictionary> _addByParent; - private readonly Dictionary> _duplicateByParent; - private readonly Dictionary _baseById; - private readonly List _diagnostics; - private readonly HashSet _seenIds = new HashSet(StringComparer.Ordinal); - - public ApplyContext( - Dictionary setVisibility, - Dictionary setLabel, - HashSet hide, - Dictionary> reorder, - Dictionary> addByParent, - Dictionary> duplicateByParent, - Dictionary baseById, - List diagnostics) - { - _setVisibility = setVisibility; - _setLabel = setLabel; - _hide = hide; - _reorder = reorder; - _addByParent = addByParent; - _duplicateByParent = duplicateByParent; - _baseById = baseById; - _diagnostics = diagnostics; - } - - public List RebuildChildren(string parentKey, IReadOnlyList baseChildren) - { - var result = new List(); - foreach (var child in baseChildren) - { - _seenIds.Add(child.StableId); - if (_hide.Contains(child.StableId)) - continue; - result.Add(RebuildNode(child)); - } - - // Insert customer-added nodes under this parent at their recorded indices. - if (_addByParent.TryGetValue(parentKey, out var added)) - { - foreach (var addOp in added) - { - _seenIds.Add(addOp.StableId); - var addedNode = CreateAddedNode(addOp); - if (addedNode != null) - result.Insert(ClampIndex(addOp.Index, result.Count), addedNode); - } - } - - // Insert duplicate-of-shipped-node copies under this parent. - if (_duplicateByParent.TryGetValue(parentKey, out var duplicates)) - { - foreach (var dupOp in duplicates) - { - _seenIds.Add(dupOp.StableId); - var node = CreateDuplicateNode(dupOp); - if (node != null) - result.Insert(ClampIndex(dupOp.Index, result.Count), node); - } - } - - // Reorder this parent's children if the patch reorders them. - if (_reorder.TryGetValue(parentKey, out var order)) - result = ApplyOrder(result, order); - - return result; - } - - private ViewNode RebuildNode(ViewNode node) - { - var visibility = _setVisibility.TryGetValue(node.StableId, out var v) ? v : node.Visibility; - var label = _setLabel.TryGetValue(node.StableId, out var l) ? l : node.Label; - var children = RebuildChildren(node.StableId, node.Children); - return CloneWith(node, visibility, label, children); - } - - private ViewNode CreateAddedNode(ViewOverrideOperation addOp) - { - if (_baseById.ContainsKey(addOp.StableId)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "override-duplicate-id", - $"addNode '{addOp.StableId}' collides with an existing node id; skipped to preserve id uniqueness", - addOp.StableId)); - return null; - } - var kind = addOp.NodeKind ?? ViewNodeKind.Field; - var classification = string.IsNullOrEmpty(addOp.Editor) - ? EditorClassification.GroupingNone - : EditorClassification.Known; - var children = RebuildChildren(addOp.StableId, Array.Empty()); - return new ViewNode( - addOp.StableId, kind, addOp.Label, null, addOp.Field, addOp.Editor, - classification, addOp.WritingSystem, addOp.Visibility ?? ViewVisibility.Always, - ViewExpansion.NotApplicable, false, null, children); - } - - // Returns the duplicated node, or null (with a diagnostic) when the source is missing or has - // children (subtree duplication is not supported; never a silent wrong copy). - private ViewNode CreateDuplicateNode(ViewOverrideOperation dupOp) - { - if (string.IsNullOrEmpty(dupOp.SourceStableId) || - !_baseById.TryGetValue(dupOp.SourceStableId, out var source)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "duplicate-source-missing", - $"duplicateNode '{dupOp.StableId}' references source '{dupOp.SourceStableId}', which is not in the shipped definition", - dupOp.StableId)); - return null; - } - - if (source.Children.Count > 0) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "duplicate-with-children-unsupported", - $"duplicateNode '{dupOp.StableId}' copies '{dupOp.SourceStableId}', which has children; subtree duplication is not yet supported", - dupOp.StableId)); - return null; - } - - if (_baseById.ContainsKey(dupOp.StableId)) - { - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "override-duplicate-id", - $"duplicateNode '{dupOp.StableId}' collides with an existing node id; skipped to preserve id uniqueness", - dupOp.StableId)); - return null; - } - return CloneWithId(source, dupOp.StableId); - } - - private static List ApplyOrder(List nodes, IReadOnlyList order) - { - var byId = nodes.ToDictionary(n => n.StableId, StringComparer.Ordinal); - var ordered = new List(); - foreach (var id in order) - { - if (byId.TryGetValue(id, out var n)) - { - ordered.Add(n); - byId.Remove(id); - } - } - // Any children not named in the order keep their original relative position at the end. - foreach (var n in nodes) - { - if (byId.ContainsKey(n.StableId)) - ordered.Add(n); - } - return ordered; - } - - public void ReportUnresolved(ViewDefinitionOverride patch) - { - foreach (var op in patch.Operations) - { - var isInsert = op.Kind == ViewOverrideOperationKind.AddNode - || op.Kind == ViewOverrideOperationKind.DuplicateNode; - var key = isInsert ? (op.ParentStableId ?? RootParentKey) : op.StableId; - // The root is always a valid target (root inserts and root-level reorder); a parent needs that parent. - if (key == RootParentKey) - continue; - if (_seenIds.Contains(key)) - continue; - _diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-stale-target", - $"override operation '{op.Kind}' references '{key}', which is not in the shipped definition", - key)); - } - } - - private static int ClampIndex(int? index, int count) - => Math.Max(0, Math.Min(index ?? count, count)); - } - - private static void AppendByParent(Dictionary> map, ViewOverrideOperation op) - { - var key = op.ParentStableId ?? RootParentKey; - if (!map.TryGetValue(key, out var list)) - map[key] = list = new List(); - list.Add(op); - } - - private static void SortByIndexThenId(Dictionary> map) - { - foreach (var list in map.Values) - list.Sort((a, b) => - { - var byIndex = (a.Index ?? 0).CompareTo(b.Index ?? 0); - return byIndex != 0 ? byIndex : string.CompareOrdinal(a.StableId, b.StableId); - }); - } - - private static Dictionary FlattenBase(IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node) - { - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = node; - foreach (var child in node.Children) - Visit(child); - } - - foreach (var root in roots) - Visit(root); - return map; - } - - // Reconstruct an immutable node with overridden visibility/label/children, copying every - // other field. Every trailing optional constructor argument must be passed, or that - // field is stripped. - private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string label, IReadOnlyList children) - => new ViewNode( - n.StableId, n.Kind, label, n.Abbreviation, n.Field, n.RawEditor, n.EditorClassification, - n.WritingSystem, visibility, n.Expansion, n.Indented, n.TargetLayout, children, - n.LocalizationKey, n.AutomationId, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, - n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, - n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue); - - // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh, - // non-colliding identity (the renderer derives one from the new StableId by convention). - private static ViewNode CloneWithId(ViewNode n, string newId) - => new ViewNode( - newId, n.Kind, n.Label, n.Abbreviation, n.Field, n.RawEditor, n.EditorClassification, - n.WritingSystem, n.Visibility, n.Expansion, n.Indented, n.TargetLayout, n.Children, - n.LocalizationKey, null, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, - n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, - n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue); - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs deleted file mode 100644 index d0bf1c5f59..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The kind of sparse override operation a customer layout customization maps to. Deliberately small: - /// the representable customer edits over the shipped definition. Anything outside this set is reported - /// as a diagnostic, never silently dropped. - /// - public enum ViewOverrideOperationKind - { - /// Change a node's (legacy visibility= edit). - SetVisibility, - - /// Override a node's label text (legacy per-project relabeling). - SetLabel, - - /// Reorder a node's children (same child set, different order). - ReorderChildren, - - /// A node present in the shipped definition that the override removed/hid. - HideNode, - - /// A node the customer added that is not in the shipped definition (with parent + index). - AddNode, - - /// Duplicate an existing shipped node under a new id (legacy copy-with-suffix authoring op). - /// Authoring-only: the differ never infers it; it is applied/serialized for hand-authored patches. - DuplicateNode - } - - /// - /// One sparse override operation, keyed by the shipped node's . This is - /// the delta-against-stable-identity model for a per-project layout customization. - /// - public sealed class ViewOverrideOperation - { - public ViewOverrideOperation( - ViewOverrideOperationKind kind, - string stableId, - ViewVisibility? visibility = null, - string label = null, - IReadOnlyList childOrder = null, - string parentStableId = null, - int? index = null, - ViewNodeKind? nodeKind = null, - string field = null, - string editor = null, - string sourceStableId = null, - string writingSystem = null) - { - Kind = kind; - StableId = stableId ?? throw new ArgumentNullException(nameof(stableId)); - Visibility = visibility; - Label = label; - ChildOrder = childOrder ?? (IReadOnlyList)Array.Empty(); - ParentStableId = parentStableId; - Index = index; - NodeKind = nodeKind; - Field = field; - Editor = editor; - SourceStableId = sourceStableId; - WritingSystem = writingSystem; - } - - public ViewOverrideOperationKind Kind { get; } - - /// The shipped node this operation patches (for AddNode, the new node's id). - public string StableId { get; } - - /// New visibility for (also carried on AddNode). - public ViewVisibility? Visibility { get; } - - /// New label for (also carried on AddNode). - public string Label { get; } - - /// New child order (StableIds) for . - public IReadOnlyList ChildOrder { get; } - - /// For : the parent the new node is inserted under. - public string ParentStableId { get; } - - /// For : the insertion index among the parent's children. - public int? Index { get; } - - /// For : the new node's structural kind. - public ViewNodeKind? NodeKind { get; } - - /// For : the new node's field binding. - public string Field { get; } - - /// For : the new node's raw editor. - public string Editor { get; } - - /// For : the new node's writing system. - public string WritingSystem { get; } - - /// For : the shipped node to copy from. - public string SourceStableId { get; } - - /// Deterministic summary used for snapshot/round-trip tests. - public override string ToString() - { - switch (Kind) - { - case ViewOverrideOperationKind.SetVisibility: - return $"setVisibility {StableId} -> {Visibility}"; - case ViewOverrideOperationKind.SetLabel: - return $"setLabel {StableId} -> {Label}"; - case ViewOverrideOperationKind.ReorderChildren: - return $"reorderChildren {StableId} -> [{string.Join(",", ChildOrder)}]"; - case ViewOverrideOperationKind.HideNode: - return $"hideNode {StableId}"; - case ViewOverrideOperationKind.AddNode: - return $"addNode {StableId} under {ParentStableId}@{Index} ({NodeKind})"; - case ViewOverrideOperationKind.DuplicateNode: - return $"duplicateNode {StableId} from {SourceStableId} under {ParentStableId}@{Index}"; - default: - return $"{Kind} {StableId}"; - } - } - } - - /// - /// A sparse per-project override: the ordered set of representable operations against a shipped - /// definition, plus diagnostics for every customization that is NOT representable (so "migrated" - /// carries no silent asterisk). - /// - public sealed class ViewDefinitionOverride - { - /// The override-format version. - public const int CurrentFormatVersion = 1; - - public ViewDefinitionOverride( - string className, - string layoutName, - string layoutType, - IReadOnlyList operations, - IReadOnlyList diagnostics, - int formatVersion = CurrentFormatVersion) - { - ClassName = className; - LayoutName = layoutName; - LayoutType = layoutType; - Operations = operations ?? (IReadOnlyList)Array.Empty(); - Diagnostics = diagnostics ?? (IReadOnlyList)Array.Empty(); - FormatVersion = formatVersion; - } - - public int FormatVersion { get; } - public string ClassName { get; } - public string LayoutName { get; } - public string LayoutType { get; } - public IReadOnlyList Operations { get; } - public IReadOnlyList Diagnostics { get; } - - /// True when the override carries no operations (the project did not customize this layout). - public bool IsEmpty => Operations.Count == 0; - } - - /// - /// Computes a sparse from a shipped definition and the same - /// layout as customized by a project. Both inputs are the typed IR the importer already produces, so - /// the diff keys on - /// -- the identity scheme the semantic baselines already use -- instead of a second one. - /// - /// Representable edits (visibility, label, child reorder, node hidden) become operations; everything - /// else (added nodes, changed binding/editor/kind) becomes an explicit diagnostic. Output is - /// deterministic: operations and diagnostics are ordered by StableId then kind. - /// - public static class ViewDefinitionOverrideDiffer - { - private const string RootParentKey = ""; // matches the applier's normalized root-parent key - - public static ViewDefinitionOverride Diff(ViewDefinitionModel shipped, ViewDefinitionModel overridden) - { - if (shipped == null) throw new ArgumentNullException(nameof(shipped)); - if (overridden == null) throw new ArgumentNullException(nameof(overridden)); - - var shippedNodes = Flatten(shipped.Roots); - var overriddenNodes = Flatten(overridden.Roots); - var shippedParents = BuildParentIndex(shipped.Roots); - var overriddenParents = BuildParentIndex(overridden.Roots); - - var operations = new List(); - var diagnostics = new List(); - - foreach (var pair in shippedNodes) - { - var stableId = pair.Key; - var shippedNode = pair.Value; - - if (!overriddenNodes.TryGetValue(stableId, out var overriddenNode)) - { - // The customer removed/hid this shipped node. - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.HideNode, stableId)); - continue; - } - - // A change to binding/editor/kind is not a representable sparse override; report it rather - // than emit a wrong patch (never a silent drop). - if (shippedNode.Kind != overriddenNode.Kind || - !string.Equals(shippedNode.Field, overriddenNode.Field, StringComparison.Ordinal) || - !string.Equals(shippedNode.RawEditor, overriddenNode.RawEditor, StringComparison.Ordinal)) - { - diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-unrepresentable-change", - $"node '{stableId}' changed binding/editor/kind in the override; not representable as a sparse patch", - stableId)); - continue; - } - - if (shippedParents.TryGetValue(stableId, out var shippedPlace) - && overriddenParents.TryGetValue(stableId, out var overriddenPlace) - && !string.Equals(shippedPlace.ParentId, overriddenPlace.ParentId, StringComparison.Ordinal)) - { - diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, - "override-reparent-unrepresentable", - $"node '{stableId}' moved to a different parent in the override; reparenting is not representable as a sparse patch", - stableId)); - continue; - } - - if (shippedNode.Visibility != overriddenNode.Visibility) - { - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, - stableId, visibility: overriddenNode.Visibility)); - } - - if (!string.Equals(shippedNode.Label, overriddenNode.Label, StringComparison.Ordinal)) - { - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.SetLabel, - stableId, label: overriddenNode.Label)); - } - - AppendReorderIfNeeded(operations, stableId, shippedNode, overriddenNode); - } - - foreach (var stableId in overriddenNodes.Keys) - { - if (shippedNodes.ContainsKey(stableId)) - continue; - - // A customer-added node: representable as an AddNode op carrying the parent + insert index - // and the new node's identity. (An applier must order AddNode ops parent-before-child; the - // parent reference makes that ordering recoverable even though ops sort by StableId.) - var added = overriddenNodes[stableId]; - overriddenParents.TryGetValue(stableId, out var place); - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.AddNode, stableId, - visibility: added.Visibility, label: added.Label, - parentStableId: place.ParentId, index: place.Index, - nodeKind: added.Kind, field: added.Field, editor: added.RawEditor, - writingSystem: added.WritingSystem)); - } - - AppendReorderIfNeeded(operations, RootParentKey, - shipped.Roots.Select(r => r.StableId).ToList(), - overridden.Roots.Select(r => r.StableId).ToList()); - - operations.Sort(CompareOperations); - diagnostics.Sort((a, b) => - { - var byPath = string.CompareOrdinal(a.NodePath, b.NodePath); - return byPath != 0 ? byPath : string.CompareOrdinal(a.Code, b.Code); - }); - - return new ViewDefinitionOverride( - overridden.ClassName, overridden.LayoutName, overridden.LayoutType, operations, diagnostics); - } - - private static void AppendReorderIfNeeded( - List operations, string stableId, ViewNode shippedNode, ViewNode overriddenNode) - => AppendReorderIfNeeded(operations, stableId, - shippedNode.Children.Select(c => c.StableId).ToList(), - overriddenNode.Children.Select(c => c.StableId).ToList()); - - // Emits a ReorderChildren op (keyed by parent, or RootParentKey for the root list) when the child - // SET is identical and only the order differs. Added/removed children are handled elsewhere. - private static void AppendReorderIfNeeded( - List operations, string key, - List shippedOrder, List overriddenOrder) - { - if (shippedOrder.Count != overriddenOrder.Count) - return; - if (!new HashSet(shippedOrder).SetEquals(overriddenOrder)) - return; - if (shippedOrder.SequenceEqual(overriddenOrder, StringComparer.Ordinal)) - return; - - operations.Add(new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, - key, childOrder: overriddenOrder)); - } - - private static int CompareOperations(ViewOverrideOperation a, ViewOverrideOperation b) - { - var byId = string.CompareOrdinal(a.StableId, b.StableId); - return byId != 0 ? byId : a.Kind.CompareTo(b.Kind); - } - - private static Dictionary Flatten(IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node) - { - // StableIds are unique per definition; if a malformed tree repeats one, keep the first so the - // diff is deterministic rather than order-dependent. - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = node; - foreach (var child in node.Children) - Visit(child); - } - - foreach (var root in roots) - Visit(root); - return map; - } - - // Maps each node's StableId to its parent's StableId (null for roots) and its index among siblings, - // so an AddNode op records where a customer-added node was inserted. - private static Dictionary BuildParentIndex( - IReadOnlyList roots) - { - var map = new Dictionary(StringComparer.Ordinal); - void Visit(ViewNode node, string parentId, int index) - { - if (!map.ContainsKey(node.StableId)) - map[node.StableId] = (parentId, index); - for (var i = 0; i < node.Children.Count; i++) - Visit(node.Children[i], node.StableId, i); - } - - for (var i = 0; i < roots.Count; i++) - Visit(roots[i], null, i); - return map; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs deleted file mode 100644 index 1c4f2577e5..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The runtime "where the per-field gear-menu command lands" helper for the Avalonia detail view - /// (advanced-entry-view). Two pure jobs over the immutable IR + override model, both unit-testable - /// without any XCore/Inventory/LCModel dependency: - /// - /// - /// -- given a compiled and - /// a - /// node's template , returns the node's current visibility, - /// its parent StableId (null at the root), the parent's ordered child StableIds, and the node's - /// index among them. This is what "Move Field"/"Field Visibility" need to build a - /// -- the parent + sibling order the legacy code read - /// from the - /// live DataTree, here read from the composed definition instead. - /// -- folds one new operation into an existing - /// , replacing any prior op of the same kind+target (the gear - /// menu re-setting a field's visibility supersedes the last choice; a second move supersedes the - /// last reorder) and appending otherwise. Pure: returns a new override, never mutates the input. - /// - /// - /// Anything stored here keys on the template StableId (the runtime "{stableId}@{hvo}" suffix must be - /// stripped by the caller via ), because StableIds are layout-local - /// paths and the override store is keyed by (ClassName, LayoutName). - /// - public static class ViewDefinitionOverrideEditor - { - /// - /// The runtime field StableId carries an "@{hvo}" object suffix (DetailComposer's - /// StableId(node, obj)); the override targets the template id, so strip from the LAST '@'. - /// Suffixed forms like "{id}@{hvo}/item3" or "{id}@{hvo}/pic0" keep their trailing path segment - /// after the hvo is removed, matching the template id the importer assigned. - /// - public static string StripRuntimeSuffix(string runtimeStableId) - { - if (string.IsNullOrEmpty(runtimeStableId)) - return runtimeStableId; - var at = runtimeStableId.IndexOf('@'); - if (at < 0) - return runtimeStableId; - // Everything before '@' is the template id; any path after the hvo (e.g. "/item3") rides along. - var afterHvo = runtimeStableId.IndexOf('/', at); - return afterHvo < 0 - ? runtimeStableId.Substring(0, at) - : runtimeStableId.Substring(0, at) + runtimeStableId.Substring(afterHvo); - } - - /// - /// Locates in . Returns null when the - /// id is not present (a stale/unknown target -- the caller treats that as a no-op, not a - /// crash). - /// - public static ViewNodeLocation LocateTarget(ViewDefinitionModel model, string templateStableId) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - if (string.IsNullOrEmpty(templateStableId)) - return null; - - // Root-level scan first (parent is null). - var rootIndex = IndexOf(model.Roots, templateStableId); - if (rootIndex >= 0) - { - return new ViewNodeLocation(model.Roots[rootIndex].Visibility, null, - model.Roots.Select(n => n.StableId).ToList(), rootIndex); - } - - foreach (var root in model.Roots) - { - var found = LocateUnder(root, templateStableId); - if (found != null) - return found; - } - - return null; - } - - private static ViewNodeLocation LocateUnder(ViewNode parent, string templateStableId) - { - var index = IndexOf(parent.Children, templateStableId); - if (index >= 0) - { - return new ViewNodeLocation(parent.Children[index].Visibility, parent.StableId, - parent.Children.Select(n => n.StableId).ToList(), index); - } - - foreach (var child in parent.Children) - { - var found = LocateUnder(child, templateStableId); - if (found != null) - return found; - } - - return null; - } - - private static int IndexOf(IReadOnlyList nodes, string id) - { - for (var i = 0; i < nodes.Count; i++) - { - if (string.Equals(nodes[i].StableId, id, StringComparison.Ordinal)) - return i; - } - - return -1; - } - - /// - /// Returns the sibling order produced by moving the node at one - /// position toward the front ( = true) or back. Returns null when the move - /// is not possible (first sibling can't move up, last can't move down, single child can't move), - /// so the caller leaves the override untouched and disables the menu item. - /// - public static IReadOnlyList ComputeMovedOrder(IReadOnlyList siblingOrder, - int currentIndex, bool up) - { - if (siblingOrder == null || siblingOrder.Count < 2) - return null; - if (currentIndex < 0 || currentIndex >= siblingOrder.Count) - return null; - var swapWith = up ? currentIndex - 1 : currentIndex + 1; - if (swapWith < 0 || swapWith >= siblingOrder.Count) - return null; - - var reordered = siblingOrder.ToList(); - var tmp = reordered[currentIndex]; - reordered[currentIndex] = reordered[swapWith]; - reordered[swapWith] = tmp; - return reordered; - } - - /// - /// Folds into : a same-kind, same-target operation - /// replaces the existing one (so a field's visibility/reorder is idempotent across repeated menu - /// use); otherwise the op is appended. Pure -- the input override is never mutated. - /// - public static ViewDefinitionOverride MergeOperation(ViewDefinitionOverride patch, ViewOverrideOperation op) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - if (op == null) throw new ArgumentNullException(nameof(op)); - - var ops = new List(); - var replaced = false; - foreach (var existing in patch.Operations) - { - if (existing.Kind == op.Kind - && string.Equals(existing.StableId, op.StableId, StringComparison.Ordinal)) - { - ops.Add(op); - replaced = true; - } - else - { - ops.Add(existing); - } - } - - if (!replaced) - ops.Add(op); - - return new ViewDefinitionOverride(patch.ClassName, patch.LayoutName, patch.LayoutType, - ops, patch.Diagnostics, patch.FormatVersion); - } - } - - /// - /// Where a node sits in a compiled definition: its current visibility, its parent's StableId (null - /// at the root), the parent's ordered child StableIds, and the node's index among them. The address - /// the gear-menu commands turn into a . - /// - public sealed class ViewNodeLocation - { - public ViewNodeLocation(ViewVisibility visibility, string parentStableId, - IReadOnlyList siblingOrder, int index) - { - Visibility = visibility; - ParentStableId = parentStableId; - SiblingOrder = siblingOrder ?? Array.Empty(); - Index = index; - } - - /// The node's current visibility (after any override already applied to the model). - public ViewVisibility Visibility { get; } - - /// The parent node's template StableId, or null when the node is at the root. - public string ParentStableId { get; } - - /// The parent's children in document order (template StableIds), including this node. - public IReadOnlyList SiblingOrder { get; } - - /// This node's index within . - public int Index { get; } - - /// True when the node can move toward the front (not already first). - public bool CanMoveUp => SiblingOrder.Count > 1 && Index > 0; - - /// True when the node can move toward the back (not already last). - public bool CanMoveDown => SiblingOrder.Count > 1 && Index >= 0 && Index < SiblingOrder.Count - 1; - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs deleted file mode 100644 index 0bb6b84735..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// File-level driver for the legacy-override -> sparse-patch migration. Reads a project's - /// whole-copy - /// .fwlayout override from disk, diffs it against the shipped layout via - /// , and writes the canonical JSON patch file. - /// - /// The only piece left to the XCore caller is providing the shipped layout element - /// (resolved - /// from Inventory) and the part resolver -- those are passed in, so this whole - /// orchestration is - /// unit-testable with temp files and inline XML, with no XCore/Inventory dependency. - /// - public static class ViewDefinitionOverrideFileMigrator - { - /// - /// Migrates the override file at against - /// . If is non-empty, the canonical - /// JSON patch is written there. Returns the patch (also when no file is written). - /// - public static ViewDefinitionOverride MigrateOverrideFile( - XElement shippedLayout, - string overrideFilePath, - IPartResolver parts, - string outputPatchPath = null, - IViewDefinitionImporter importer = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (string.IsNullOrEmpty(overrideFilePath)) throw new ArgumentNullException(nameof(overrideFilePath)); - if (parts == null) throw new ArgumentNullException(nameof(parts)); - if (!File.Exists(overrideFilePath)) - throw new FileNotFoundException("Override layout file not found.", overrideFilePath); - - var overriddenLayout = LoadLayout(overrideFilePath); - var patch = ViewDefinitionOverrideMigrator.MigrateLayout(shippedLayout, overriddenLayout, parts, importer); - - if (!string.IsNullOrEmpty(outputPatchPath)) - { - var dir = Path.GetDirectoryName(outputPatchPath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - File.WriteAllText(outputPatchPath, ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - } - - return patch; - } - - // The legacy override file (Inventory.PersistOverrideElement) is a copy of the customized - // element. Accept either a file whose root is or one that wraps it. - private static XElement LoadLayout(string path) - { - var root = XElement.Load(path); - if (root.Name.LocalName == "layout") - return root; - var layout = root.Descendants().FirstOrDefault(e => e.Name.LocalName == "layout"); - if (layout == null) - throw new InvalidDataException($"No element found in override file '{path}'."); - return layout; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs deleted file mode 100644 index 018a6cfdf6..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Canonical JSON wire format for a per-project : deterministic, - /// sparse, keyed by StableId, with a `formatVersion` header. Mirrors the conventions of - /// (Newtonsoft, ordered keys, defaults omitted) so the - /// override store and the base store read alike and diff cleanly under review. - /// - public static class ViewDefinitionOverrideJsonSerializer - { - // Stable wire tokens for the operation kinds (camelCase, decoupled from the C# enum names). - private static readonly Dictionary OpToWire = - new Dictionary - { - { ViewOverrideOperationKind.SetVisibility, "setVisibility" }, - { ViewOverrideOperationKind.SetLabel, "setLabel" }, - { ViewOverrideOperationKind.ReorderChildren, "reorderChildren" }, - { ViewOverrideOperationKind.HideNode, "hideNode" }, - { ViewOverrideOperationKind.AddNode, "addNode" }, - { ViewOverrideOperationKind.DuplicateNode, "duplicateNode" } - }; - - private static readonly Dictionary WireToOp = - OpToWire.ToDictionary(kv => kv.Value, kv => kv.Key, StringComparer.Ordinal); - - public static string Serialize(ViewDefinitionOverride patch) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - - var root = new JObject - { - ["formatVersion"] = patch.FormatVersion, - ["class"] = patch.ClassName, - ["name"] = patch.LayoutName, - ["type"] = patch.LayoutType, - ["operations"] = new JArray(patch.Operations.Select(WriteOperation)) - }; - - // Diagnostics are the audit record: present only when the override had non-representable parts. - if (patch.Diagnostics.Count > 0) - root["diagnostics"] = new JArray(patch.Diagnostics.Select(WriteDiagnostic)); - - return root.ToString(Formatting.Indented); - } - - public static ViewDefinitionOverride Deserialize(string json) - { - if (string.IsNullOrEmpty(json)) throw new ArgumentNullException(nameof(json)); - var root = JObject.Parse(json); - - var version = (int?)root["formatVersion"] ?? -1; - if (version != ViewDefinitionOverride.CurrentFormatVersion) - throw new InvalidDataException( - $"Unsupported override formatVersion {version} (expected {ViewDefinitionOverride.CurrentFormatVersion})."); - - var operations = ((JArray)root["operations"] ?? new JArray()).Select(ReadOperation).ToList(); - var diagnostics = ((JArray)root["diagnostics"] ?? new JArray()).Select(ReadDiagnostic).ToList(); - - return new ViewDefinitionOverride( - (string)root["class"] ?? "", - (string)root["name"] ?? "", - (string)root["type"] ?? "detail", - operations, - diagnostics, - version); - } - - private static JObject WriteOperation(ViewOverrideOperation op) - { - var o = new JObject - { - ["op"] = OpToWire[op.Kind], - ["id"] = op.StableId - }; - switch (op.Kind) - { - case ViewOverrideOperationKind.SetVisibility: - o["visibility"] = op.Visibility?.ToString(); - break; - case ViewOverrideOperationKind.SetLabel: - o["label"] = op.Label; - break; - case ViewOverrideOperationKind.ReorderChildren: - o["childOrder"] = new JArray(op.ChildOrder); - break; - case ViewOverrideOperationKind.HideNode: - break; - case ViewOverrideOperationKind.AddNode: - o["parent"] = op.ParentStableId; - o["index"] = op.Index; - o["nodeKind"] = op.NodeKind?.ToString(); - if (op.Label != null) o["label"] = op.Label; - if (op.Field != null) o["field"] = op.Field; - if (op.Editor != null) o["editor"] = op.Editor; - if (op.WritingSystem != null) o["ws"] = op.WritingSystem; - if (op.Visibility.HasValue) o["visibility"] = op.Visibility.Value.ToString(); - break; - case ViewOverrideOperationKind.DuplicateNode: - o["source"] = op.SourceStableId; - o["parent"] = op.ParentStableId; - o["index"] = op.Index; - break; - } - return o; - } - - private static ViewOverrideOperation ReadOperation(JToken token) - { - var o = (JObject)token; - var wire = (string)o["op"]; - if (wire == null || !WireToOp.TryGetValue(wire, out var kind)) - throw new InvalidDataException($"Unknown override operation '{wire}'."); - - var stableId = (string)o["id"]; - switch (kind) - { - case ViewOverrideOperationKind.SetVisibility: - var visText = (string)o["visibility"]; - var vis = ParseEnum(visText, "visibility"); - return new ViewOverrideOperation(kind, stableId, visibility: vis); - case ViewOverrideOperationKind.SetLabel: - return new ViewOverrideOperation(kind, stableId, label: (string)o["label"]); - case ViewOverrideOperationKind.ReorderChildren: - var order = ((JArray)o["childOrder"] ?? new JArray()).Select(t => (string)t).ToList(); - return new ViewOverrideOperation(kind, stableId, childOrder: order); - case ViewOverrideOperationKind.AddNode: - var addKindText = (string)o["nodeKind"]; - var addKind = addKindText == null - ? (ViewNodeKind?)null - : ParseEnum(addKindText, "nodeKind"); - var addVisText = (string)o["visibility"]; - var addVis = addVisText == null - ? (ViewVisibility?)null - : ParseEnum(addVisText, "visibility"); - return new ViewOverrideOperation(kind, stableId, - visibility: addVis, label: (string)o["label"], - parentStableId: (string)o["parent"], index: (int?)o["index"], - nodeKind: addKind, field: (string)o["field"], editor: (string)o["editor"], - writingSystem: (string)o["ws"]); - case ViewOverrideOperationKind.DuplicateNode: - return new ViewOverrideOperation(kind, stableId, - parentStableId: (string)o["parent"], index: (int?)o["index"], - sourceStableId: (string)o["source"]); - default: - return new ViewOverrideOperation(kind, stableId); - } - } - - private static JObject WriteDiagnostic(ViewDiagnostic diag) - => new JObject - { - ["severity"] = diag.Severity.ToString(), - ["code"] = diag.Code, - ["path"] = diag.NodePath, - ["message"] = diag.Message - }; - - private static ViewDiagnostic ReadDiagnostic(JToken token) - { - var o = (JObject)token; - var severity = ParseEnum((string)o["severity"], "severity"); - return new ViewDiagnostic(severity, (string)o["code"], (string)o["message"], (string)o["path"]); - } - - // Parses an enum value from committed JSON, turning a null/garbage token into a controlled - // InvalidDataException (the load path catches it) rather than a raw ArgumentException/NRE. - private static TEnum ParseEnum(string text, string field) where TEnum : struct - { - if (string.IsNullOrEmpty(text) || !Enum.TryParse(text, ignoreCase: false, out var value) - || !Enum.IsDefined(typeof(TEnum), value)) - throw new InvalidDataException($"Invalid {field} value '{text}' in override patch."); - return value; - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs deleted file mode 100644 index 204d2ab2c9..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Xml.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// Migrates a legacy whole-copy .fwlayout override into a sparse, stable-id-keyed - /// . It imports both the shipped layout and the - /// project's customized copy to the typed IR (reusing ), then diffs them - /// by . Because a legacy override copies the shipped <layout> - /// under the same name, the imported StableIds align by position, so the diff is exactly the customer's - /// edits -- replacing the lossy whole-tree LayoutMerger with per-node operations. - /// - /// This is the framework-neutral migration core: it takes XML in and produces the patch. The thin - /// remaining wrapper (read the shipped layout from Inventory and the override file from the - /// project ConfigurationSettings folder, then write the patch file) is the XCore-coupled driver layer, - /// kept out of here so the migration logic stays unit-testable with inline XML. - /// - public static class ViewDefinitionOverrideMigrator - { - /// Migrates one shipped/overridden <layout> pair into a sparse override patch. - public static ViewDefinitionOverride MigrateLayout( - XElement shippedLayout, - XElement overriddenLayout, - IPartResolver parts, - IViewDefinitionImporter importer = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (overriddenLayout == null) throw new ArgumentNullException(nameof(overriddenLayout)); - if (parts == null) throw new ArgumentNullException(nameof(parts)); - - importer = importer ?? new XmlLayoutImporter(); - var shippedModel = importer.Import(shippedLayout, parts); - var overriddenModel = importer.Import(overriddenLayout, parts); - return ViewDefinitionOverrideDiffer.Diff(shippedModel, overriddenModel); - } - - /// String overload for callers/tests holding the layout XML as text. - public static ViewDefinitionOverride MigrateLayout( - string shippedLayoutXml, - string overriddenLayoutXml, - IPartResolver parts, - IViewDefinitionImporter importer = null) - { - if (shippedLayoutXml == null) throw new ArgumentNullException(nameof(shippedLayoutXml)); - if (overriddenLayoutXml == null) throw new ArgumentNullException(nameof(overriddenLayoutXml)); - return MigrateLayout(XElement.Parse(shippedLayoutXml), XElement.Parse(overriddenLayoutXml), parts, importer); - } - - /// Migrates and serializes the patch to canonical JSON in one step (the committed artifact). - public static string MigrateLayoutToJson( - XElement shippedLayout, - XElement overriddenLayout, - IPartResolver parts, - IViewDefinitionImporter importer = null) - => ViewDefinitionOverrideJsonSerializer.Serialize( - MigrateLayout(shippedLayout, overriddenLayout, parts, importer)); - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs deleted file mode 100644 index 99c8c210d4..0000000000 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition -{ - /// - /// The per-project home of the sparse patches that drive the - /// Avalonia detail view's per-field "Field Visibility"/"Move Field" commands: sparse JSON - /// patch documents keyed by StableId, stored as files in the project ConfigurationSettings folder. - /// One file per (class, layout); the override layer -- not the legacy Inventory store -- is - /// what Compose - /// actually reads. - /// - /// Pure FwAvalonia: the caller (the xWorks host) resolves the project ConfigurationSettings folder - /// from LcmFileHelper.GetConfigSettingsDir and hands the path here, so this stays LCModel-free - /// and unit-testable with a temp directory. Patches load lazily and cache per (class, layout); each - /// mutation re-serializes the one file it touched (via ). - /// - public sealed class ViewDefinitionOverrideStore - { - // Distinct extension so these never collide with the legacy whole-copy "{Class}.fwlayout" files - // in the same folder, and so the file name reads as "what + which layout" for support staff. - internal const string FileExtension = ".viewoverride.json"; - - private readonly string _directory; - private readonly Dictionary<(string Class, string Layout), ViewDefinitionOverride> _cache - = new Dictionary<(string, string), ViewDefinitionOverride>(); - private readonly object _sync = new object(); - - public ViewDefinitionOverrideStore(string configurationSettingsDirectory) - { - _directory = configurationSettingsDirectory - ?? throw new ArgumentNullException(nameof(configurationSettingsDirectory)); - } - - /// - /// The patch for (, ), or null when the - /// project never customized that layout. Loads from disk on first access and caches; a corrupt or - /// version-mismatched file is treated as "no override" (load failure is reported to - /// rather than crashing compose -- the legacy Inventory - /// drops stale - /// overrides too). - /// - public ViewDefinitionOverride TryGet(string className, string layoutName, - Action onLoadError = null) - { - if (string.IsNullOrEmpty(className) || string.IsNullOrEmpty(layoutName)) - return null; - - var key = (className, layoutName); - lock (_sync) - { - if (_cache.TryGetValue(key, out var cached)) - return cached; - - ViewDefinitionOverride loaded = null; - var path = PathFor(className, layoutName); - try - { - if (File.Exists(path)) - { - var patch = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(path)); - // Guard against a hand-edited/renamed file whose header disagrees with its name. - if (string.Equals(patch.ClassName, className, StringComparison.Ordinal) - && string.Equals(patch.LayoutName, layoutName, StringComparison.Ordinal)) - { - loaded = patch.IsEmpty ? null : patch; - } - } - } - catch (Exception e) - { - onLoadError?.Invoke(path, e); - loaded = null; - } - - _cache[key] = loaded; - return loaded; - } - } - - /// - /// Persists for its (ClassName, LayoutName) and refreshes the - /// cache. An - /// empty patch deletes the file (the project no longer customizes that layout), so an undo-to-base - /// leaves no stale override behind -- the same "no file = shipped definition" contract - /// the loader - /// relies on. - /// - public void Save(ViewDefinitionOverride patch) - { - if (patch == null) throw new ArgumentNullException(nameof(patch)); - if (string.IsNullOrEmpty(patch.ClassName) || string.IsNullOrEmpty(patch.LayoutName)) - throw new ArgumentException("Override must carry a class and layout name to be stored."); - - var key = (patch.ClassName, patch.LayoutName); - var path = PathFor(patch.ClassName, patch.LayoutName); - lock (_sync) - { - if (patch.IsEmpty) - { - if (File.Exists(path)) - File.Delete(path); - _cache[key] = null; - return; - } - - Directory.CreateDirectory(_directory); - File.WriteAllText(path, ViewDefinitionOverrideJsonSerializer.Serialize(patch)); - _cache[key] = patch; - } - } - - /// The on-disk path for a (class, layout) patch (also the file the loader - /// reads). - public string PathFor(string className, string layoutName) - => Path.Combine(_directory, MakeFileName(className, layoutName)); - - // "{Class}.{Layout}.viewoverride.json" -- sanitized so an exotic layout name can never - // escape the - // folder or collide with a path separator (layout names are inventory tokens, but be defensive). - internal static string MakeFileName(string className, string layoutName) - { - var safeClass = Sanitize(className); - var safeLayout = Sanitize(layoutName); - return safeClass + "." + safeLayout + FileExtension; - } - - private static string Sanitize(string token) - { - if (string.IsNullOrEmpty(token)) - return "_"; - var invalid = Path.GetInvalidFileNameChars(); - return new string(token.Select(c => Array.IndexOf(invalid, c) >= 0 || c == '.' ? '_' : c).ToArray()); - } - } -} diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index c3104f8500..176ee4f1f1 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -165,13 +165,14 @@ private void ProcessPart( { var stableId = $"{parentPath}/#{output.Count}"; var refName = (string)callerEl.Attribute("ref"); + var sourceCallerPath = LegacyLayoutCallerPath.Get(callerEl); // Custom-field placeholder: or ref="_CustomFieldPlaceholder". if (callerEl.Attribute("customFields") != null || refName == "_CustomFieldPlaceholder") { output.Add(MakeLeaf(stableId, ViewNodeKind.CustomFieldPlaceholder, "(custom fields)", null, null, null, EditorClassification.GroupingNone, null, ViewVisibility.Always, - ViewExpansion.NotApplicable, indented, null)); + ViewExpansion.NotApplicable, indented, null, sourceCallerPath)); return; } @@ -219,7 +220,8 @@ private void ProcessPart( ParseExpansion(Attr(callerEl, "expansion")), indented, null, recoveredChildren, menuId: Attr(callerEl, "menu"), - hotlinksId: Attr(callerEl, "hotlinks"))); + hotlinksId: Attr(callerEl, "hotlinks"), + sourceCallerPath: sourceCallerPath)); } } return; @@ -233,7 +235,7 @@ private void ProcessPart( // Each sibling after the first needs its own stable id so they don't collide. var childStableId = i == 0 ? stableId : $"{parentPath}/#{output.Count}"; var node = CreateNode(contents[i], callerEl, parts, className, layoutType, childStableId, - indented, diagnostics); + indented, diagnostics, sourceCallerPath); if (node != null) { output.Add(node); @@ -249,7 +251,8 @@ private ViewNode CreateNode( string layoutType, string stableId, bool indented, - List diagnostics) + List diagnostics, + string sourceCallerPath = null) { var label = Attr(callerEl, "label") ?? Attr(contentEl, "label"); var abbreviation = Attr(callerEl, "abbr") ?? Attr(contentEl, "abbr"); @@ -342,7 +345,8 @@ private ViewNode CreateNode( } var children = new List(); - AddInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); + AddInlineChildren(childElements, parts, className, layoutType, stableId, children, + diagnostics, sourceCallerPath); // A jtview slice (editor="jtview") names the nested layout to compose for this // object in its caller's param (legacy SliceFactory jtview: param ?? node layout attr). @@ -394,7 +398,8 @@ private ViewNode CreateNode( localizationKey, automationId, routing, boldEmphasis, fontScalePercent, menuId, contextMenuId, hotlinksId, chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, - visibleWritingSystems: visibleWss); + visibleWritingSystems: visibleWss, + sourceCallerPath: sourceCallerPath); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -413,7 +418,8 @@ private ViewNode CreateNode( visibleWritingSystems: visibleWss, // Legacy toggleValue= on a boolean slice (the displayed checkbox is the // logical inverse of the stored property); carried so the composer inverts read+write. - toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false); + toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false, + sourceCallerPath: sourceCallerPath); } case "obj": case "seq": @@ -436,7 +442,8 @@ private ViewNode CreateNode( ghostLabel: Attr(contentEl, "ghostLabel") ?? Attr(callerEl, "ghostLabel"), // The layout's post-create hook rides the node so the composer's ghost // setter can invoke it the way GhostStringSliceView.MakeRealObject does. - ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod")); + ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod"), + sourceCallerPath: sourceCallerPath); } // Conditional display: / shows content only when the condition // passes (fails, for ifnot), evaluated via XmlVc.ConditionPasses. Preserved @@ -451,12 +458,12 @@ private ViewNode CreateNode( var children = new List(); AddConditionalChildren(contentEl, parts, className, layoutType, stableId, indented, - children, diagnostics); + children, diagnostics, sourceCallerPath); return new ViewNode(stableId, ViewNodeKind.Conditional, label, abbreviation, Attr(contentEl, "field"), null, EditorClassification.GroupingNone, null, visibility, expansion, indented, null, children, localizationKey, automationId, routing, menuId: menuId, contextMenuId: contextMenuId, hotlinksId: hotlinksId, - condition: condition); + condition: condition, sourceCallerPath: sourceCallerPath); } // holds branches (first passing one renders) and an optional @@ -488,17 +495,19 @@ private ViewNode CreateNode( var branchChildren = new List(); AddConditionalChildren(clause, parts, className, layoutType, branchId, indented, - branchChildren, diagnostics); + branchChildren, diagnostics, sourceCallerPath); branches.Add(new ViewNode(branchId, ViewNodeKind.Conditional, null, null, Attr(clause, "field"), null, EditorClassification.GroupingNone, null, ViewVisibility.Always, ViewExpansion.NotApplicable, indented, null, - branchChildren, condition: branchCondition)); + branchChildren, condition: branchCondition, + sourceCallerPath: sourceCallerPath)); } return new ViewNode(stableId, ViewNodeKind.ChoiceGroup, label, abbreviation, null, null, EditorClassification.GroupingNone, null, visibility, expansion, indented, null, branches, localizationKey, automationId, routing, menuId: menuId, - contextMenuId: contextMenuId, hotlinksId: hotlinksId); + contextMenuId: contextMenuId, hotlinksId: hotlinksId, + sourceCallerPath: sourceCallerPath); } default: @@ -590,7 +599,8 @@ private void AddConditionalChildren( string parentPath, bool indented, List output, - List diagnostics) + List diagnostics, + string sourceCallerPath) { foreach (var child in container.Elements()) { @@ -604,7 +614,7 @@ private void AddConditionalChildren( default: { var node = CreateNode(child, child, parts, className, layoutType, - $"{parentPath}/#{output.Count}", indented, diagnostics); + $"{parentPath}/#{output.Count}", indented, diagnostics, sourceCallerPath); if (node != null) output.Add(node); break; @@ -671,12 +681,14 @@ private void AddInlineChildren( string layoutType, string parentPath, List output, - List diagnostics) + List diagnostics, + string sourceCallerPath) { foreach (var child in childElements) { var stableId = $"{parentPath}/#{output.Count}"; - var node = CreateNode(child, child, parts, className, layoutType, stableId, false, diagnostics); + var node = CreateNode(child, child, parts, className, layoutType, stableId, false, + diagnostics, sourceCallerPath); if (node != null) { output.Add(node); @@ -722,7 +734,8 @@ private void AddInjectedChildren( continue; } - var node = CreateNode(content, child, parts, "", layoutType, stableId, false, diagnostics); + var node = CreateNode(content, child, parts, "", layoutType, stableId, false, diagnostics, + LegacyLayoutCallerPath.Get(child)); if (node != null) { output.Add(node); @@ -754,9 +767,11 @@ private static ViewNode MakeLeaf( string stableId, ViewNodeKind kind, string label, string abbreviation, string field, string editor, EditorClassification classification, string ws, ViewVisibility visibility, ViewExpansion expansion, bool indented, string targetLayout, - string localizationKey = null, string automationId = null, HostRouting routing = HostRouting.Inherit) + string sourceCallerPath = null, string localizationKey = null, string automationId = null, + HostRouting routing = HostRouting.Inherit) => new ViewNode(stableId, kind, label, abbreviation, field, editor, classification, ws, visibility, - expansion, indented, targetLayout, System.Array.Empty(), localizationKey, automationId, routing); + expansion, indented, targetLayout, System.Array.Empty(), localizationKey, automationId, + routing, sourceCallerPath: sourceCallerPath); private static string Attr(XElement el, string name) => (string)el.Attribute(name); diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 94b0fd63d6..d7e5b26b36 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -3,11 +3,9 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using System.Xml.Linq; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; @@ -41,12 +39,10 @@ public ComposedDetail(DetailModel model, IDetailEditContext editContext) } /// - /// Resolves the per-project sparse override patch for a compiled (class, layout), or null when the - /// project did not customize that layout. The host wires this to the - /// ViewDefinitionOverrideStore in the project ConfigurationSettings folder; tests supply an - /// in-memory resolver. Kept a delegate so the composer needs no reference to the file-backed store. + /// Resolves an immutable effective project layout snapshot, or null when no layout matches. /// - public delegate ViewDefinitionOverride ViewDefinitionOverrideResolver(string className, string layoutName); + public delegate ViewDefinitionSourceSnapshot ViewDefinitionSourceResolver(string className, + string layoutName, string choiceGuid); /// /// Composes the COMPLETE Lexical Edit view for an entry (sections 6/7): walks the compiled @@ -86,18 +82,15 @@ private static CompilerSources GetSources() } } - // Observable memoization: counts the expensive snapshot builds (layout - // lookup + layout.ToString() + fingerprint + compile). A repeat compose must not grow it. - private static int s_snapshotCompileCount; - - internal static int SnapshotCompileCount => s_snapshotCompileCount; + /// + /// Gets the immutable merged parts XML used by the shipped composition source, or null + /// when the shipped sources are unavailable. + /// + internal static string GetMergedPartsXml() + => GetSources()?.PartsXml; /// - /// The loaded sources, immutable for the process lifetime: the layout lookup is - /// indexed once and compiled definitions are memoized per (starting class, layout), - /// so repeat composes and the per-item menu peeks never rebuild or re-fingerprint - /// the ~300KB parts snapshot. Class ids and the class hierarchy are fixed LCModel - /// metadata, so the memo is safe across caches. + /// The immutable shipped sources used when an effective project source has no layout. /// private sealed class CompilerSources { @@ -106,17 +99,12 @@ private sealed class CompilerSources // the right one (legacy distinguishes e.g. 11 RnGenericRec/Normal layouts only by // choiceGuid). public Dictionary<(string ClassName, string Type, string Name), List> LayoutIndex; - // Memoized per (starting class, layout, choiceGuid) -- choiceGuid is part of the - // identity so two - // record Types on the same class compile to two distinct models (never a cache collision). - public readonly ConcurrentDictionary<(int ClassId, string LayoutName, string ChoiceGuid), ViewDefinitionModel> CompiledModels - = new ConcurrentDictionary<(int, string, string), ViewDefinitionModel>(); } public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false, SlicePluginRegistry plugins = null, - ViewDefinitionOverrideResolver overrides = null) - => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides); + ViewDefinitionSourceResolver source = null) + => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, source); /// /// Compose the structured detail view for ANY record root + starting layout -- the @@ -129,7 +117,7 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH /// public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layoutName = "Normal", bool showHiddenFields = false, SlicePluginRegistry plugins = null, - ViewDefinitionOverrideResolver overrides = null, + ViewDefinitionSourceResolver source = null, string layoutChoiceField = null) { if (obj == null) throw new ArgumentNullException(nameof(obj)); @@ -141,7 +129,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou // picks the matching layout variant instead of the document-first one. var choiceGuid = ResolveLayoutChoiceGuid(cache, obj, layoutChoiceField); - var root = CompileForObject(cache, obj, layoutName, choiceGuid, overrides); + var root = CompileForObject(cache, obj, layoutName, choiceGuid, source); if (root == null) return null; @@ -150,7 +138,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou // bridges the gap (plugin factories run at render time, not compose). IDetailEditContext composedContext = null; var state = new ComposeState(cache, showHiddenFields, - plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides); + plugins ?? SlicePluginRegistry.Default, () => composedContext, source); state.EnterModel(root); foreach (var node in root.Roots) state.Walk(node, obj, 0); @@ -281,14 +269,10 @@ public FieldEditHandler HandlerFor(string stableId) } private readonly bool _showHidden; - // The per-project override resolver, threaded into every CompileForObject - // so a descended object's layout gets its own patch applied; plus the (class, layout) of the - // model currently being walked, captured onto each emitted field so the host's per-field - // gear-menu commands target the right override file. A stack so the entry context restores - // after a nested object's walk returns. - private readonly ViewDefinitionOverrideResolver _overrides; + private readonly ViewDefinitionSourceResolver _source; private readonly Stack<(string ClassName, string LayoutName)> _modelContext = new Stack<(string, string)>(); + private readonly Stack _sourceCallerPaths = new Stack(); // The plugin registry consulted FIRST for every // custom slice, plus the deferred accessor for the edit context plugin factories // receive (resolved when the factory runs, after Compose has built the context). @@ -319,7 +303,8 @@ public FieldEditHandler HandlerFor(string stableId) // CHOICE-UNSAFE KEY: this cache key omits choiceGuid while the menu // binding is derived from the compiled layout's root, which can differ per choice variant. It is // correct ONLY because descent currently compiles every embedded object with choiceGuid=null - // (CompileForObjectWithOverrides), so within one compose there is no choice variance to collide. + // (CompileForObjectWithSource), so within one compose there is no choice variance to + // collide. // If descent is ever changed to thread choiceGuid through, change this key to // (ClassId, LayoutName, choiceGuid) in the SAME change, or this becomes a wrong-menu bug. private readonly Dictionary<(int ClassId, string LayoutName), (string MenuId, string HotlinksId)> _itemMenuBindings @@ -327,13 +312,13 @@ public FieldEditHandler HandlerFor(string stableId) public ComposeState(LcmCache cache, bool showHiddenFields, SlicePluginRegistry plugins, Func editContextAccessor, - ViewDefinitionOverrideResolver overrides = null) + ViewDefinitionSourceResolver source = null) { _cache = cache; _showHidden = showHiddenFields; _plugins = plugins; _editContextAccessor = editContextAccessor; - _overrides = overrides; + _source = source; _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; } @@ -358,6 +343,8 @@ private void AddField(DetailField field) field.ClassName = ctx.ClassName; field.LayoutName = ctx.LayoutName; } + if (_sourceCallerPaths.Count > 0) + field.SourceCallerPath = _sourceCallerPaths.Peek(); Fields.Add(field); } @@ -497,10 +484,9 @@ void AddAll(IEnumerable systems) return _writingSystemFonts; } - // Every CompileForObject in the walk goes through here so the per-project - // override patch for the descended object's own (class, layout) is applied to its model too. - private ViewDefinitionModel CompileForObjectWithOverrides(ICmObject obj, string layoutName) - => CompileForObject(_cache, obj, layoutName, _overrides); + // Every descended object uses the root composition's effective project source. + private ViewDefinitionModel CompileForObjectWithSource(ICmObject obj, string layoutName) + => CompileForObject(_cache, obj, layoutName, _source); // Viewing parity: "show hidden fields" surfaces visibility=never fields and keeps empty // ifdata fields visible, exactly like legacy m_fShowAllFields. @@ -513,35 +499,37 @@ public void Walk(ViewNode node, ICmObject obj, int depth) if (IsHidden(node) || depth > MaxDepth) return; - switch (node.Kind) + _sourceCallerPaths.Push(node.SourceCallerPath); + try { - case ViewNodeKind.Field: - WalkField(node, obj, depth); - break; - case ViewNodeKind.Group: - WalkGroup(node, obj, depth); - break; - case ViewNodeKind.ObjectAtom: - WalkObjectAtom(node, obj, depth); - break; - case ViewNodeKind.Sequence: - WalkSequence(node, obj, depth); - break; - case ViewNodeKind.CustomFieldPlaceholder: - // Runtime expansion of `customFields="here"` from - // live MDC metadata. - WalkCustomFields(node, obj, depth); - break; - case ViewNodeKind.Conditional: - // Legacy / -- content composes only when the per-object - // condition - // passes (DataTree.ProcessSubpartNode cases "if"/"ifnot"). - WalkConditional(node, obj, depth); - break; - case ViewNodeKind.ChoiceGroup: - // Legacy -- first passing (or the ) only. - WalkChoiceGroup(node, obj, depth); - break; + switch (node.Kind) + { + case ViewNodeKind.Field: + WalkField(node, obj, depth); + break; + case ViewNodeKind.Group: + WalkGroup(node, obj, depth); + break; + case ViewNodeKind.ObjectAtom: + WalkObjectAtom(node, obj, depth); + break; + case ViewNodeKind.Sequence: + WalkSequence(node, obj, depth); + break; + case ViewNodeKind.CustomFieldPlaceholder: + WalkCustomFields(node, obj, depth); + break; + case ViewNodeKind.Conditional: + WalkConditional(node, obj, depth); + break; + case ViewNodeKind.ChoiceGroup: + WalkChoiceGroup(node, obj, depth); + break; + } + } + finally + { + _sourceCallerPaths.Pop(); } } @@ -801,7 +789,8 @@ private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) return new ViewNode($"{placeholder.StableId}/custom:{fieldName}", ViewNodeKind.Field, _mdc.GetFieldLabel(flid), null, fieldName, rawEditor, EditorClassification.Known, wsSpec, ViewVisibility.Always, ViewExpansion.NotApplicable, placeholder.Indented, - null, null, menuId: "mnuDataTree-Help"); + null, null, menuId: "mnuDataTree-Help", + sourceCallerPath: placeholder.SourceCallerPath); } // The node's chooserLink wins; else the row derives its tool like the legacy path. @@ -953,13 +942,9 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) WalkUnsupported(node, obj, depth); break; case DetailEditorCategory.EmbeddedView: - // An embedded formatted view (legacy jtview / ViewSlice + XmlView) composes the - // nested layout's fields INLINE for this same object, at depth+1 -- the - // recursive - // sub-view the legacy XmlView renders. WalkEmbeddedView reuses the - // CompileForObjectWithOverrides/EnterModel/Walk descent (the visited-set guards - // cycles); when the nested layout cannot be resolved it degrades to the - // read-only ShortName row rather than vanishing. + // Embedded views inline a nested layout for the same object. + // Missing layouts use a read-only ShortName row. + // Cycles terminate safely. WalkEmbeddedView(node, obj, depth); break; case DetailEditorCategory.Command: @@ -2941,7 +2926,7 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) if (_itemMenuBindings.TryGetValue((item.ClassID, layoutName), out var cached)) return cached; - var compiled = CompileForObjectWithOverrides(item, layoutName); + var compiled = CompileForObjectWithSource(item, layoutName); string menu = null, hotlinks = null; if (compiled != null) { @@ -2983,7 +2968,7 @@ private void WalkEmbeddedView(ViewNode node, ICmObject obj, int depth) try { - var compiled = CompileForObjectWithOverrides(obj, layoutName); + var compiled = CompileForObjectWithSource(obj, layoutName); if (compiled != null && compiled.Roots.Count > 0) { EnterModel(compiled); @@ -3008,7 +2993,7 @@ private void DescendInto(ViewNode node, ICmObject target, int depth) if (!_visited.Add((target.Hvo, layoutName))) return; - var compiled = CompileForObjectWithOverrides(target, layoutName); + var compiled = CompileForObjectWithSource(target, layoutName); if (compiled != null && compiled.Roots.Count > 0) { // Rows from the descended model are stamped with ITS (class, @@ -3090,12 +3075,6 @@ internal static IReadOnlyList ResolveWritingSystems return WritingSystemServices.GetWritingSystemList(cache, magicId, forceIncludeEnglish: false); } - /// - /// Compiles the layout for an object's class, walking base classes the way legacy - /// DataTree - /// does (e.g. MoStemAllomorph -> MoForm) for both layout lookup and part resolution. - /// Memoized per (starting class, layout) for the lifetime of the loaded sources. - /// /// /// Resolve the layout-choice GUID for a record whose detail layout is type-selected via /// a layoutChoiceField (e.g. RnGenericRec/Normal keyed on the record's Type possibility). @@ -3131,43 +3110,30 @@ internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject o => CompileForObject(cache, obj, layoutName, null, null); internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName, - ViewDefinitionOverrideResolver overrides) - => CompileForObject(cache, obj, layoutName, null, overrides); + ViewDefinitionSourceResolver source) + => CompileForObject(cache, obj, layoutName, null, source); /// - /// Compiles (with the legacy base-class walk) and, when supplies a - /// per-project patch for the resulting (class, layout), returns the patched model - /// CRITICAL: the cache () holds - /// the SHIPPED model only -- the override is applied on the way OUT to a fresh copy - /// ( is pure), so a patched project never poisons - /// the process-wide cache that other projects/classes read. + /// Compiles the effective project layout when supplied, otherwise using shipped sources. /// internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName, - string choiceGuid, ViewDefinitionOverrideResolver overrides) + string choiceGuid, ViewDefinitionSourceResolver source) + => CompileForClass(cache, obj.ClassID, layoutName, choiceGuid, source); + + private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, string layoutName, + string choiceGuid, ViewDefinitionSourceResolver source) { + var mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; + if (source != null) + { + var projectSnapshot = source(mdc.GetClassName(classId), layoutName, choiceGuid); + return projectSnapshot == null ? null : Compiler.Compile(projectSnapshot); + } + var sources = GetSources(); if (sources == null) return null; - var shipped = sources.CompiledModels.GetOrAdd((obj.ClassID, layoutName, choiceGuid ?? string.Empty), - key => CompileForClass(cache, key.ClassId, key.LayoutName, key.ChoiceGuid, sources)); - if (shipped == null || overrides == null) - return shipped; - - // The compiled model's ClassName is the class where the layout was actually found (possibly a - // base class of obj.ClassID); key the override by that, matching how the patch was authored. - var patch = overrides(shipped.ClassName, shipped.LayoutName); - return patch == null || patch.IsEmpty - ? shipped - : ViewDefinitionOverrideApplier.Apply(shipped, patch); - } - - private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, string layoutName, - string choiceGuid, CompilerSources sources) - { - Interlocked.Increment(ref s_snapshotCompileCount); - - var mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; var baseClassMap = new Dictionary(StringComparer.Ordinal); var clsid = classId; XElement layout = null; diff --git a/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs new file mode 100644 index 0000000000..ccc631b542 --- /dev/null +++ b/Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel.Core.KernelInterfaces; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Creates immutable view-definition snapshots from an effective project layout inventory. + /// + public sealed class InventoryViewDefinitionSource + { + private readonly Inventory _layouts; + private readonly string _partsXml; + private readonly IFwMetaDataCache _metadataCache; + + /// + /// Creates a source backed by the current effective layouts and immutable merged parts + /// XML. + /// + /// A constructor argument is null. + public InventoryViewDefinitionSource(Inventory layouts, string partsXml, + IFwMetaDataCache metadataCache) + { + _layouts = layouts ?? throw new ArgumentNullException(nameof(layouts)); + _partsXml = partsXml ?? throw new ArgumentNullException(nameof(partsXml)); + _metadataCache = metadataCache ?? throw new ArgumentNullException(nameof(metadataCache)); + } + + /// + /// Gets the effective detail layout snapshot, or null when the class hierarchy has no + /// match. + /// + public ViewDefinitionSourceSnapshot GetSnapshot(string className, string layoutName, + string choiceGuid = null) + { + var classId = _metadataCache.GetClassId(className); + var baseClassMap = new Dictionary(StringComparer.Ordinal); + string resolvedClassName; + string layoutXml; + + while (true) + { + resolvedClassName = _metadataCache.GetClassName(classId); + var layout = _layouts.GetElement("layout", + new[] { resolvedClassName, "detail", layoutName, choiceGuid }); + if (layout == null) + { + layout = _layouts.GetElement("layout", + new[] { resolvedClassName, "detail", layoutName, null }); + } + + if (layout != null) + { + layoutXml = layout.OuterXml; + break; + } + + if (classId == 0) + return null; + var baseId = _metadataCache.GetBaseClsId(classId); + if (baseId == classId) + return null; + baseClassMap[resolvedClassName] = _metadataCache.GetClassName(baseId); + classId = baseId; + } + + var ancestorClassId = classId; + while (ancestorClassId != 0) + { + var baseId = _metadataCache.GetBaseClsId(ancestorClassId); + if (baseId == ancestorClassId || baseId == 0) + break; + baseClassMap[_metadataCache.GetClassName(ancestorClassId)] = + _metadataCache.GetClassName(baseId); + ancestorClassId = baseId; + } + + return new ViewDefinitionSourceSnapshot(resolvedClassName, "detail", layoutXml, + _partsXml, new ReadOnlyDictionary(baseClassMap)); + } + } +} diff --git a/Src/xWorks/Avalonia/DetailOverrideMigration.cs b/Src/xWorks/Avalonia/DetailOverrideMigration.cs deleted file mode 100644 index 76eeeaf5cb..0000000000 --- a/Src/xWorks/Avalonia/DetailOverrideMigration.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.Xml; -using System.Xml.Linq; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; -using XCore; - -namespace SIL.FieldWorks.XWorks -{ - /// - /// xWorks adapter that migrates a project's legacy whole-copy .fwlayout override into a sparse - /// canonical JSON patch. It bridges the live to - /// the framework-neutral, fully-tested migration core - /// ( + ). - /// - /// The caller supplies the pristine shipped layout (resolved from the appropriate - /// non-overridden source); this adapter does not decide the baseline -- that choice (e.g. a - /// base - /// inventory vs. the project inventory whose overrides are already merged) belongs to the caller and - /// is the one piece needing a real-project smoke test before production use. - /// - public static class DetailOverrideMigration - { - /// - /// Framework-neutral core: shipped layout + parts inventory as XElements. Unit-testable - /// with inline - /// XML -- it composes the tested and - /// . - /// - public static ViewDefinitionOverride MigrateProjectOverride( - XElement shippedLayout, - XElement partsInventory, - string overrideFilePath, - string outputPatchPath = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (partsInventory == null) throw new ArgumentNullException(nameof(partsInventory)); - - var parts = new DictionaryPartResolver(partsInventory); - return ViewDefinitionOverrideFileMigrator.MigrateOverrideFile( - shippedLayout, overrideFilePath, parts, outputPatchPath); - } - - /// - /// Live- bridge: adapts the shipped layout node and the parts inventory root - /// (System.Xml) to the XElement core. The must be the pristine - /// shipped layout (see the type remarks on baseline selection). - /// - public static ViewDefinitionOverride MigrateProjectOverride( - XmlNode shippedLayout, - Inventory partsInventory, - string overrideFilePath, - string outputPatchPath = null) - { - if (shippedLayout == null) throw new ArgumentNullException(nameof(shippedLayout)); - if (partsInventory == null) throw new ArgumentNullException(nameof(partsInventory)); - - return MigrateProjectOverride( - XElement.Parse(shippedLayout.OuterXml), - XElement.Parse(partsInventory.Root.OuterXml), - overrideFilePath, - outputPatchPath); - } - } -} diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 6349454c14..39534c8412 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -41,6 +41,54 @@ namespace SIL.FieldWorks.XWorks /// public partial class RecordEditView { + internal readonly struct PersistentCommandTargetIdentity + : IEquatable + { + internal PersistentCommandTargetIdentity(int hvo, string fieldName, + string className, string layoutName, string callerPath) + { + Hvo = hvo; + FieldName = fieldName; + ClassName = className; + LayoutName = layoutName; + CallerPath = callerPath; + } + + internal int Hvo { get; } + + internal string FieldName { get; } + + internal string ClassName { get; } + + internal string LayoutName { get; } + + internal string CallerPath { get; } + + public bool Equals(PersistentCommandTargetIdentity other) + { + return Hvo == other.Hvo + && string.Equals(FieldName, other.FieldName, StringComparison.Ordinal) + && string.Equals(ClassName, other.ClassName, StringComparison.Ordinal) + && string.Equals(LayoutName, other.LayoutName, StringComparison.Ordinal) + && string.Equals(CallerPath, other.CallerPath, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + => obj is PersistentCommandTargetIdentity other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = Hvo; + hash = (hash * 397) ^ (FieldName?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (ClassName?.GetHashCode() ?? 0); + hash = (hash * 397) ^ (LayoutName?.GetHashCode() ?? 0); + return (hash * 397) ^ (CallerPath?.GetHashCode() ?? 0); + } + } + } + private UIFramework m_activeUIFramework; private readonly EditControlFactory m_lexicalEditControlFactory; private readonly UIFrameworkSelectionService m_frameworkSelectionService = new UIFrameworkSelectionService(); @@ -50,13 +98,8 @@ public partial class RecordEditView // open undo task is never orphaned (an orphan makes the shutdown Save throw "Commit at wrong place"). private readonly DetailEditContextHolder m_detailEditContext = new DetailEditContextHolder(); private AvaloniaDetailRefreshController m_avaloniaRefreshController; - // The per-project home of the sparse view-definition override patches that - // drive the Avalonia detail view's per-field Field Visibility / Move Field commands. Lazily built from - // the project ConfigurationSettings folder; the detail view reads it at Compose and the gear - // menu writes it. The legacy WinForms DataTree path NEVER touches this -- it keeps its - // Inventory - // store untouched. - private ViewDefinitionOverrideStore m_viewOverrideStore; + private InventoryViewDefinitionSource m_inventoryViewDefinitionSource; + private string m_inventoryViewDefinitionProjectName; // The approved baseline-adapter ids -- the ONLY routes allowed to drive hidden legacy // infrastructure while Avalonia is active. internal const string CommandMenuRoutingAdapterId = "command-menu-routing"; @@ -331,15 +374,16 @@ private void ShowAvaloniaEntry(ICmObject obj) ComposedDetail composed = null; try { + var source = GetInventoryViewDefinitionSource(); composed = lexEntry != null ? DetailComposer.Compose(lexEntry, Cache, showHidden, - overrides: ResolveViewOverride) + source: source.GetSnapshot) // Non-entry roots compose against the tool's configured layout // (m_layoutName, default "Normal"); a type-selected layout (m_layoutChoiceField, e.g. // Notebook RnGenericRec keyed on "Type") resolves to the right variant inside Compose. : DetailComposer.Compose(obj, Cache, string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, - overrides: ResolveViewOverride, + source: source.GetSnapshot, layoutChoiceField: m_layoutChoiceField); if (composed != null) { @@ -349,9 +393,7 @@ private void ShowAvaloniaEntry(ICmObject obj) } catch (Exception e) { - // The user silently gets the fixed first-slice view instead of the full entry; - // that degradation must be diagnosable from the log, not just a debugger. - Logger.WriteError("Full-entry composition failed; falling back to the first slice.", e); + Logger.WriteError("Avalonia detail composition failed; using the host fallback.", e); } if (detail == null) @@ -388,6 +430,30 @@ private void ShowAvaloniaEntry(ICmObject obj) GetPersistedLabelColumnWidth, PersistLabelColumnWidth); } + private InventoryViewDefinitionSource GetInventoryViewDefinitionSource() + { + var projectName = Cache?.ProjectId?.Name; + if (string.IsNullOrEmpty(projectName)) + throw new InvalidOperationException("The project layout inventory key is unavailable."); + if (m_inventoryViewDefinitionSource != null + && m_inventoryViewDefinitionProjectName == projectName) + { + return m_inventoryViewDefinitionSource; + } + + var layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + if (layouts == null) + throw new InvalidOperationException("The project layout inventory is unavailable."); + var partsXml = DetailComposer.GetMergedPartsXml(); + if (string.IsNullOrEmpty(partsXml)) + throw new InvalidOperationException("The merged detail parts are unavailable."); + + m_inventoryViewDefinitionSource = new InventoryViewDefinitionSource(layouts, partsXml, + Cache.MetaDataCacheAccessor); + m_inventoryViewDefinitionProjectName = projectName; + return m_inventoryViewDefinitionSource; + } + /// /// Called when a writing system editor gains focus. Never throws: a focus /// event can race view teardown, so failures are logged instead. @@ -494,11 +560,7 @@ private void OnDetailMenuRequested(DetailMenuRequest request) // adapter menu remains the fallback if materialization fails. try { - // Retarget the per-field Field Visibility / Move Field commands - // to the project override layer for the Avalonia detail view; every other command (Help, - // inserts, writing-system menu, ...) keeps its normal mediator dispatch. - var interceptor = BuildOverrideCommandInterceptor(request.Field); - var items = XCoreMenuBridge.CreateMenuItems(window, idArray, interceptor); + var items = CreateNativeDetailMenuItems(request.Field, idArray); if (items.Count > 0) { // A keyboard-opened menu anchors under the row it came from; a @@ -515,6 +577,7 @@ private void OnDetailMenuRequested(DetailMenuRequest request) } window.ShowContextMenu(idArray, AdapterMenuScreenPoint(request), null, null); + RefreshAvaloniaDetail(); } catch (Exception e) { @@ -522,168 +585,68 @@ private void OnDetailMenuRequested(DetailMenuRequest request) } } - // The adapter fallback needs a raw screen point: cursor position for a right-click, - // the anchor's bottom-left otherwise. Both corners are mapped since - // RTL flow mirrors X in PointToScreen. - private static System.Drawing.Point AdapterMenuScreenPoint(DetailMenuRequest request) + private IReadOnlyList CreateNativeDetailMenuItems(DetailField field, + string[] menuIds) { - var anchor = request.AnchorControl; - if (request.OpenAtPointer || anchor == null) - return System.Windows.Forms.Cursor.Position; - var left = Avalonia.VisualExtensions.PointToScreen(anchor, - new Avalonia.Point(0, anchor.Bounds.Height)); - var right = Avalonia.VisualExtensions.PointToScreen(anchor, - new Avalonia.Point(anchor.Bounds.Width, anchor.Bounds.Height)); - return new System.Drawing.Point(Math.Min(left.X, right.X), left.Y); + var window = m_propertyTable.GetValue("window"); + var hasBroadTarget = EnsureMenuCommandTarget(field.ObjectHvo, field.Field); + var hasPersistentTarget = hasBroadTarget + && TrySetPersistentMenuCommandTarget(field, false); + return XCoreMenuBridge.CreateMenuItems(window, menuIds, + choice => CreateLegacyCommandMenuItem(field, choice, hasPersistentTarget)); } - // The per-(class, layout) override file lives in this project's - // ConfigurationSettings folder. Built lazily and - // reused; one store per view instance, so it caches the patches it has loaded. - private ViewDefinitionOverrideStore ViewOverrideStore + private DetailMenuItem CreateLegacyCommandMenuItem(DetailField field, ChoiceBase choice, + bool hasPersistentTarget) { - get - { - if (m_viewOverrideStore == null && Cache?.ProjectId?.ProjectFolder != null) + var persistent = IsPersistentLayoutCommand(choice); + var display = choice.GetDisplayProperties(); + var captured = choice; + return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), + (!persistent || hasPersistentTarget) && display.Enabled, + display.Checked, null, () => { - m_viewOverrideStore = new ViewDefinitionOverrideStore( - LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.ProjectFolder)); - } - - return m_viewOverrideStore; - } + var canExecute = persistent + ? EnsurePersistentMenuCommandTarget(field) + : EnsureMenuCommandTarget(field.ObjectHvo, field.Field); + if (!canExecute) + return; + var currentDisplay = captured.GetDisplayProperties(); + if (!currentDisplay.Visible || !currentDisplay.Enabled) + return; + captured.OnClick(null, EventArgs.Empty); + RefreshAvaloniaDetail(); + }); } - // The resolver the composer calls for each compiled (class, layout); null result = shipped - // definition. A load failure is logged, not fatal -- compose then uses the shipped - // definition. - private ViewDefinitionOverride ResolveViewOverride(string className, string layoutName) - => ViewOverrideStore?.TryGet(className, layoutName, - (path, error) => Logger.WriteError("Failed to load view-definition override '" + path - + "'; using the shipped definition.", error)); - - /// - /// Builds the interceptor that retargets the per-field Field Visibility and - /// Move Field commands to the project override layer for the Avalonia detail view. Returns null - /// (intercept nothing -- every command keeps its normal mediator dispatch) when the - /// clicked row - /// carries no (class, layout) context, e.g. the first-slice fallback rows; that keeps the legacy - /// behavior intact when the override layer cannot be addressed. - /// - private Func BuildOverrideCommandInterceptor(DetailField field) + private static bool IsPersistentLayoutCommand(ChoiceBase choice) { - if (field == null || string.IsNullOrEmpty(field.ClassName) || string.IsNullOrEmpty(field.LayoutName) - || ViewOverrideStore == null) - { - return null; - } - - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); - // Locate the clicked node in the field's OWN compiled model (with any current override - // already applied), so visibility checkmarks and move enablement reflect the live state. - ViewNodeLocation location = null; - try - { - if (Cache.ServiceLocator.ObjectRepository.TryGetObject(field.ObjectHvo, out var fieldObj)) - { - var model = DetailComposer.CompileForObject(Cache, fieldObj, field.LayoutName, - ResolveViewOverride); - if (model != null) - location = ViewDefinitionOverrideEditor.LocateTarget(model, templateId); - } - } - catch (Exception e) + switch (choice?.HelpId) { - Logger.WriteError("Resolving the field's override target failed; the gear-menu field " - + "commands fall back to the legacy path for this row.", e); - return null; + case "CmdAlwaysVisible": + case "CmdIfData": + case "CmdNormallyHidden": + case "CmdDataTree-MoveFieldUp": + case "CmdDataTree-MoveFieldDown": + return true; + default: + return false; } - - if (location == null) - return null; // unknown/stale target: leave commands on the legacy path rather than guess. - - return choice => - { - switch (choice.HelpId) - { - case "CmdAlwaysVisible": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.Always); - case "CmdIfData": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.IfData); - case "CmdNormallyHidden": - return VisibilityItem(choice, field, templateId, location, ViewVisibility.Never); - case "CmdDataTree-MoveFieldUp": - return MoveItem(choice, field, location, up: true); - case "CmdDataTree-MoveFieldDown": - return MoveItem(choice, field, location, up: false); - default: - return null; // not a field command: keep its normal mediator dispatch. - } - }; - } - - // A Field Visibility menu item: checked when it is the field's current visibility, executes the - // SetVisibility override mutation (idempotent -- re-choosing the current value is a - // harmless write). - private DetailMenuItem VisibilityItem(ChoiceBase choice, DetailField field, - string templateId, ViewNodeLocation location, ViewVisibility target) - { - var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text); - var isChecked = location.Visibility == target; - return new DetailMenuItem(label, isEnabled: true, isChecked: isChecked, children: null, - execute: () => ApplyFieldVisibility(field, templateId, target)); - } - - // A Move Field item: disabled at the first sibling (up) / last sibling (down) / when alone. - private DetailMenuItem MoveItem(ChoiceBase choice, DetailField field, - ViewNodeLocation location, bool up) - { - var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text); - var canMove = up ? location.CanMoveUp : location.CanMoveDown; - return new DetailMenuItem(label, isEnabled: canMove, isChecked: false, children: null, - execute: canMove ? (Action)(() => ApplyMoveField(field, location, up)) : null); - } - - // Writes a SetVisibility op for the field's template id into the project override and recomposes. - private void ApplyFieldVisibility(DetailField field, string templateId, ViewVisibility target) - { - var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibility, templateId, - visibility: target); - MutateOverrideAndRefresh(field, op); - } - - // Writes a ReorderChildren op on the field's PARENT (the sibling order with this field swapped one - // position) into the project override and recomposes. A no-op when the move is not possible. - private void ApplyMoveField(DetailField field, ViewNodeLocation location, bool up) - { - var moved = ViewDefinitionOverrideEditor.ComputeMovedOrder(location.SiblingOrder, location.Index, up); - if (moved == null || string.IsNullOrEmpty(location.ParentStableId)) - return; // first/last/only sibling, or a root-level row with no parent to reorder. - var op = new ViewOverrideOperation(ViewOverrideOperationKind.ReorderChildren, - location.ParentStableId, childOrder: moved); - MutateOverrideAndRefresh(field, op); } - // Loads-or-creates the (class, layout) override, folds the op in, saves it, and recomposes the - // Avalonia detail view so the change is visible immediately. The legacy DataTree/Inventory is untouched. - private void MutateOverrideAndRefresh(DetailField field, ViewOverrideOperation op) + // The adapter fallback needs a raw screen point: cursor position for a right-click, + // the anchor's bottom-left otherwise. Both corners are mapped since + // RTL flow mirrors X in PointToScreen. + private static System.Drawing.Point AdapterMenuScreenPoint(DetailMenuRequest request) { - try - { - var store = ViewOverrideStore; - if (store == null) - return; - - var existing = store.TryGet(field.ClassName, field.LayoutName) - ?? new ViewDefinitionOverride(field.ClassName, field.LayoutName, "detail", null, null); - var merged = ViewDefinitionOverrideEditor.MergeOperation(existing, op); - store.Save(merged); - RefreshAvaloniaDetail(); - } - catch (Exception e) - { - Logger.WriteError("Applying the field override failed.", e); - } + var anchor = request.AnchorControl; + if (request.OpenAtPointer || anchor == null) + return System.Windows.Forms.Cursor.Position; + var left = Avalonia.VisualExtensions.PointToScreen(anchor, + new Avalonia.Point(0, anchor.Bounds.Height)); + var right = Avalonia.VisualExtensions.PointToScreen(anchor, + new Avalonia.Point(anchor.Bounds.Width, anchor.Bounds.Height)); + return new System.Drawing.Point(Math.Min(left.X, right.X), left.Y); } /// @@ -729,6 +692,11 @@ internal static FwLinkArgs CreateFollowLinkArgs(DetailLinkRequest request) // handlers require. Created lazily on first right-click; never attached/visible while the // Avalonia is active. private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) + { + EnsureMenuCommandTarget(targetHvo, fieldName); + } + + private bool EnsureMenuCommandTarget(int targetHvo, string fieldName) { // The active-host contract is enforced, not just documented: driving the hidden // legacy DataTree is legal only through an adapter id the host's contract lists. The @@ -752,7 +720,7 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) // No current record: drop any target left by a previous interaction, same // fail-loud rule as the no-slice-found path below. m_dataEntryForm.ClearCurrentSlice(); - return; + return false; } m_dataEntryForm.ShowObject(current, m_layoutName, m_layoutChoiceField, current, true); @@ -760,7 +728,7 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) { // The row carries no object, so no slice can be its target. m_dataEntryForm.ClearCurrentSlice(); - return; + return false; } // Targeting hardening: the legacy command handlers act on m_dataEntryForm.CurrentSlice, @@ -773,10 +741,10 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) // than silently leaving the wrong (or stale) CurrentSlice pointed, which would make the command // mutate the wrong object or, for Merge's class guard, silently fail. if (TrySetCurrentSliceForRow(targetHvo, fieldName)) - return; + return true; if (RealizeLazySlicesAndRetry(targetHvo, fieldName)) - return; + return true; // Fail loud, not silent: if we still cannot produce a slice for the target we must NOT leave // CurrentSlice pointed at whatever the previous interaction selected (it would mis-target the @@ -787,6 +755,85 @@ private void EnsureMenuCommandAdapter(int targetHvo, string fieldName) "Detail menu command adapter found no DataTree slice for target hvo {0} field '{1}'; " + "CurrentSlice was cleared so the command no-ops rather than mis-targeting another object.", targetHvo, fieldName ?? string.Empty)); + return false; + } + + private bool EnsurePersistentMenuCommandTarget(DetailField field) + { + if (!EnsureMenuCommandTarget(field.ObjectHvo, field.Field)) + return false; + return TrySetPersistentMenuCommandTarget(field, true); + } + + private bool TrySetPersistentMenuCommandTarget(DetailField field, bool clearOnFailure) + { + var candidates = new List(); + foreach (var sliceObj in m_dataEntryForm.Slices) + { + if (sliceObj is Slice slice && slice.Object != null && !slice.IsLazyPlaceholder) + candidates.Add(slice); + } + var identities = candidates.Select(slice => PersistentSliceIdentity(slice)).ToList(); + var target = new PersistentCommandTargetIdentity(field.ObjectHvo, field.Field, + field.ClassName, field.LayoutName, field.SourceCallerPath); + var index = ChoosePersistentTargetSliceIndex(identities, target); + if (index < 0) + { + if (clearOnFailure) + { + m_dataEntryForm.ClearCurrentSlice(); + Logger.WriteEvent(string.Format( + "Detail layout command found no unique slice for '{0}' at '{1}'; CurrentSlice was cleared.", + field.Field ?? string.Empty, field.SourceCallerPath ?? string.Empty)); + } + return false; + } + m_dataEntryForm.SetCurrentSliceForCommandTarget(candidates[index]); + return true; + } + + internal static int ChoosePersistentTargetSliceIndex( + IReadOnlyList candidates, + PersistentCommandTargetIdentity target) + { + if (candidates == null || string.IsNullOrEmpty(target.CallerPath)) + return -1; + var match = -1; + for (var i = 0; i < candidates.Count; i++) + { + if (!candidates[i].Equals(target)) + continue; + if (match >= 0) + return -1; + match = i; + } + return match; + } + + private PersistentCommandTargetIdentity PersistentSliceIdentity(Slice slice) + { + if (slice?.Key == null) + return default; + XmlNode layout = null; + XmlNode part = null; + foreach (var keyItem in slice.Key) + { + if (!(keyItem is XmlNode node)) + continue; + if (node.Name == "layout") + { + layout = node; + part = null; + } + else if (layout != null && node.Name == "part" + && node.Attributes?["ref"] != null && LegacyLayoutCallerPath.Get(node) != null) + { + part = node; + } + } + return new PersistentCommandTargetIdentity(slice.Object?.Hvo ?? 0, SliceFieldName(slice), + layout?.Attributes?["class"]?.Value, layout?.Attributes?["name"]?.Value, + LegacyLayoutCallerPath.Get(part)); } /// diff --git a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs index 5efffbd95e..9c91591ed2 100644 --- a/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs +++ b/Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs @@ -30,15 +30,10 @@ public static IReadOnlyList CreateMenuItems(XWindow window, stri => CreateMenuItems(window, menuIds, null); /// - /// As , but lets the host RETARGET specific leaf - /// commands for the Avalonia detail view (advanced-entry-view). For each command leaf, the - /// is offered the leaf (so the host can - /// read the localized label and command id from it); if it returns a non-null - /// , that item (its label/checked/enabled/execute) is used INSTEAD of - /// the default xCore-dispatched item. Returning null leaves the command on its normal mediator - /// path. This is how the per-field Field Visibility / Move Field commands route to the project - /// override layer while Help and every other item keep working unchanged. The interceptor only - /// sees leaf commands (submenus pass through). + /// Builds the menu and lets the host replace command leaves. + /// Native replacements preserve host-specific targeting and execution. + /// Returning null keeps normal xCore dispatch. + /// Submenus are not intercepted. /// public static IReadOnlyList CreateMenuItems(XWindow window, string[] menuIds, Func interceptor) @@ -87,9 +82,6 @@ private static List Convert(ChoiceGroup group, Func - /// advanced-entry-view: end-to-end coverage that the per-field gear-menu commands actually change - /// what the Avalonia detail view composes, BY GOING THROUGH the override layer the menu - /// writes -- not the - /// legacy Inventory store. Visibility overrides hide/show rows under the same showHidden semantics - /// legacy slices use; reorder overrides move sibling rows; both survive a recompose; and applying an - /// override never poisons the process-wide compiled-model cache (a compose without the patch is - /// unaffected). The composer is the real product path; the resolver here stands in for the file store - /// (which has its own round-trip tests in FwAvaloniaTests). - /// - [TestFixture] - public class DetailComposerOverrideTests : MemoryOnlyBackendProviderTestBase - { - private ILexEntry m_entry; - private IMoStemAllomorph m_morph; - - public override void TestSetup() - { - base.TestSetup(); - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => - { - m_entry = Cache.ServiceLocator.GetInstance().Create(); - m_morph = Cache.ServiceLocator.GetInstance().Create(); - m_entry.LexemeFormOA = m_morph; - m_morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); - var sense = Cache.ServiceLocator.GetInstance().Create(); - m_entry.SensesOS.Add(sense); - sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); - }); - } - - // An in-memory resolver standing in for the file-backed ViewDefinitionOverrideStore. - private static ViewDefinitionOverrideResolver Resolver(params ViewDefinitionOverride[] patches) - { - var byKey = patches.ToDictionary(p => (p.ClassName, p.LayoutName)); - return (cls, layout) => byKey.TryGetValue((cls, layout), out var patch) ? patch : null; - } - - private static ViewDefinitionOverride EntryPatch(params ViewOverrideOperation[] ops) - => new ViewDefinitionOverride("LexEntry", "Normal", "detail", ops, null); - - // The template (override-key) StableId of an entry-level field: strip the runtime "@{hvo}" suffix. - private string EntryFieldTemplateId(string field) - { - var composed = DetailComposer.Compose(m_entry, Cache); - var row = composed.Model.Fields.First(f => f.Field == field && f.ClassName == "LexEntry"); - return ViewDefinitionOverrideEditor.StripRuntimeSuffix(row.StableId); - } - - [Test] - public void Compose_StampsClassAndLayoutOnEntryFields() - { - var composed = DetailComposer.Compose(m_entry, Cache); - - var entryRows = composed.Model.Fields.Where(f => f.ObjectHvo == m_entry.Hvo).ToList(); - Assert.That(entryRows, Is.Not.Empty, "the entry must contribute at least one row"); - Assert.That(entryRows, Has.All.Property("ClassName").EqualTo("LexEntry")); - Assert.That(entryRows, Has.All.Property("LayoutName").EqualTo("Normal")); - } - - [Test] - public void Compose_StampsDescendedObjectsLayoutClass_NotTheEntrys() - { - var composed = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true); - - // Sense rows are projected from the sense's own compiled layout, so they must carry LexSense. - var senseRows = composed.Model.Fields - .Where(f => f.ClassName == "LexSense" && f.LayoutName == "Normal").ToList(); - Assert.That(senseRows, Is.Not.Empty, - "descended sense rows must be stamped with their own layout class, not the entry's"); - } - - [Test] - public void Visibility_Never_HidesRow_UnlessShowHidden() - { - // Pick a visible entry field, force it to "Normally hidden". - var baseline = DetailComposer.Compose(m_entry, Cache); - var victim = baseline.Model.Fields.First(f => f.ClassName == "LexEntry" - && f.Kind == DetailFieldKind.Text); - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(victim.StableId); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, templateId, visibility: ViewVisibility.Never))); - - var hidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(hidden.Model.Fields.Any(f => f.StableId == victim.StableId), Is.False, - "a Never field is hidden when showHidden is off"); - - var shown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - Assert.That(shown.Model.Fields.Any(f => f.StableId == victim.StableId), Is.True, - "a Never field reappears when showHidden is on"); - } - - [Test] - public void Visibility_IfData_HidesWhenEmpty_ShowsWhenNonEmpty() - { - // CitationForm is empty on this entry; force IfData and confirm the empty row hides, then - // give it data and confirm it shows. - var baseline = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true); - var citation = baseline.Model.Fields.FirstOrDefault(f => f.Field == "CitationForm" - && f.ClassName == "LexEntry"); - Assert.That(citation, Is.Not.Null, "the entry layout must offer a CitationForm row"); - var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(citation.StableId); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, templateId, visibility: ViewVisibility.IfData))); - - var emptyHidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(emptyHidden.Model.Fields.Any(f => f.Field == "CitationForm"), Is.False, - "an empty IfData field hides when showHidden is off"); - - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, - () => m_entry.CitationForm.set_String(Cache.DefaultVernWs, - TsStringUtils.MakeString("casita", Cache.DefaultVernWs))); - - var nowShown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, - overrides: resolver); - Assert.That(nowShown.Model.Fields.Any(f => f.Field == "CitationForm"), Is.True, - "a non-empty IfData field shows even when showHidden is off"); - } - - [Test] - public void Reorder_SwapsTwoSiblingRows_AndSurvivesRecompose() - { - // Find two entry-level sibling fields under a shared parent (via the SAME LocateTarget the - // menu uses), then reorder them and assert the row order swapped in the composed model. - var model = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); - var siblings = FindSiblingPair(model); - Assert.That(siblings, Is.Not.Null, "the entry layout must have a parent with two locatable fields"); - - var (parentId, firstId, secondId, order) = siblings.Value; - var moved = order.ToList(); - var idx = moved.IndexOf(secondId); - moved[idx] = moved[idx - 1]; - moved[idx - 1] = secondId; // move 'second' up one - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.ReorderChildren, parentId, childOrder: moved))); - - var reordered = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - var firstPos = RowPosition(reordered.Model, firstId); - var secondPos = RowPosition(reordered.Model, secondId); - Assert.That(secondPos, Is.LessThan(firstPos), - "the reorder override must move the second sibling's row ahead of the first"); - - // Survives a fresh recompose with the same resolver (the override is the source of truth). - var again = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, - overrides: resolver); - Assert.That(RowPosition(again.Model, secondId), Is.LessThan(RowPosition(again.Model, firstId))); - } - - [Test] - public void Override_DoesNotPoisonProcessWideCompiledCache() - { - var victimId = EntryFieldTemplateId(DetailComposer.Compose(m_entry, Cache) - .Model.Fields.First(f => f.ClassName == "LexEntry" && f.Kind == DetailFieldKind.Text).Field); - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, victimId, visibility: ViewVisibility.Never))); - - // Compose WITH the override (mutates nothing but the returned copy). - DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, overrides: resolver); - - // A subsequent compose WITHOUT the override must see the shipped definition unchanged: the - // cached model was never patched in place. - var clean = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false); - Assert.That(clean.Model.Fields.Any(f => f.StableId.StartsWith(victimId)), Is.True, - "composing without the patch must see the shipped (unhidden) field — the cache stayed clean"); - } - - [Test] - public void Override_UnknownStableId_IsNoOp_NotACrash() - { - var resolver = Resolver(EntryPatch(new ViewOverrideOperation( - ViewOverrideOperationKind.SetVisibility, "/#does/#not/#exist", visibility: ViewVisibility.Never))); - - var baseline = DetailComposer.Compose(m_entry, Cache); - var withStale = DetailComposer.Compose(m_entry, Cache, overrides: resolver); - - Assert.That(withStale.Model.Fields.Count, Is.EqualTo(baseline.Model.Fields.Count), - "a stale/unknown override target changes nothing"); - Assert.That(withStale.Model.Diagnostics.Any(d => d.Code == "override-stale-target"), Is.True, - "the stale target is reported as a diagnostic, not silently dropped"); - } - - private static int RowPosition(DetailModel model, string templateId) - { - for (var i = 0; i < model.Fields.Count; i++) - { - if (ViewDefinitionOverrideEditor.StripRuntimeSuffix(model.Fields[i].StableId) == templateId) - return i; - } - - return -1; - } - - // Finds a parent node in the compiled model with at least two field children both locatable by id. - private static (string Parent, string First, string Second, IReadOnlyList Order)? - FindSiblingPair(ViewDefinitionModel model) - { - (string, string, string, IReadOnlyList)? result = null; - void Visit(ViewNode parent) - { - if (result != null) return; - var fieldChildren = parent.Children.Where(c => c.Kind == ViewNodeKind.Field).ToList(); - if (fieldChildren.Count >= 2) - { - result = (parent.StableId, fieldChildren[0].StableId, fieldChildren[1].StableId, - parent.Children.Select(c => c.StableId).ToList()); - return; - } - - foreach (var child in parent.Children) - Visit(child); - } - - foreach (var root in model.Roots) - Visit(root); - return result; - } - } -} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index edbe9f0aa5..bb1cbc4bc2 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -590,22 +590,15 @@ public void TestLangProj_WritingSystemStore_ContainsRtlAndKhmerFixtures() "Khmer fixture declares the Khmer script for automation/manual scenario setup"); } - // Compiled definitions are memoized per (class, layout) while sources stay - // loaded, so a repeat compose reuses every layout instead of rebuilding and - // re-fingerprinting the ~300KB parts snapshot. - [Test] - public void Compose_RepeatCompose_ServesCompiledLayoutsFromTheMemo() - { - Assert.That(DetailComposer.Compose(m_entry, Cache), Is.Not.Null, - "priming compose populates the (class, layout) memo"); - var compilesAfterFirst = DetailComposer.SnapshotCompileCount; - Assert.That(compilesAfterFirst, Is.GreaterThan(0), "the first compose really compiled"); - - var second = DetailComposer.Compose(m_entry, Cache); - Assert.That(second, Is.Not.Null); - Assert.That(second.Model.Fields, Is.Not.Empty, "the memoized models still compose fully"); - Assert.That(DetailComposer.SnapshotCompileCount, Is.EqualTo(compilesAfterFirst), - "a repeat compose must not rebuild any layout snapshot"); + [Test] + public void CompileForObject_RepeatContentReusesCompiledModel() + { + var first = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + + var second = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + + Assert.That(second, Is.SameAs(first), + "equal source content must reuse the fingerprint-cached compiled model"); } [Test] diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs deleted file mode 100644 index fe22289a9a..0000000000 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using NUnit.Framework; -using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; - -namespace SIL.FieldWorks.XWorks -{ - /// - /// The xWorks override-migration adapter composes the live inventory's - /// parts + a shipped layout into the tested migration core. This exercises the framework-neutral - /// XElement overload with inline XML + a temp override file (the live-Inventory overload is a - /// thin XmlNode->XElement bridge over this same core, build-verified by the xWorks build). - /// - [TestFixture] - public class DetailOverrideMigrationTests - { - private const string PartsXml = @" - - - - - - - -"; - - private const string ShippedLayout = @" - - - -"; - - private const string OverrideLayout = @" - - - -"; - - private string _overrideFile; - private string _outputFile; - - [SetUp] - public void SetUp() - { - _overrideFile = Path.Combine(Path.GetTempPath(), "fw-" + Guid.NewGuid().ToString("N") + ".fwlayout"); - _outputFile = Path.Combine(Path.GetTempPath(), "patch-" + Guid.NewGuid().ToString("N") + ".json"); - } - - [TearDown] - public void TearDown() - { - if (File.Exists(_overrideFile)) File.Delete(_overrideFile); - if (File.Exists(_outputFile)) File.Delete(_outputFile); - } - - [Test] - public void MigrateProjectOverride_FromXElements_ProducesPatch_AndWritesJson() - { - File.WriteAllText(_overrideFile, OverrideLayout); - - var patch = DetailOverrideMigration.MigrateProjectOverride( - XElement.Parse(ShippedLayout), XElement.Parse(PartsXml), _overrideFile, _outputFile); - - var op = patch.Operations.Single(); - Assert.That(op.Kind, Is.EqualTo(ViewOverrideOperationKind.SetVisibility)); - Assert.That(op.StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - Assert.That(op.Visibility, Is.EqualTo(ViewVisibility.Never)); - - Assert.That(File.Exists(_outputFile), Is.True); - var restored = ViewDefinitionOverrideJsonSerializer.Deserialize(File.ReadAllText(_outputFile)); - Assert.That(restored.Operations.Single().StableId, Is.EqualTo("LexEntry/CfAndBib/#1")); - } - - [Test] - public void MigrateProjectOverride_NoCustomization_ProducesEmptyPatch() - { - File.WriteAllText(_overrideFile, ShippedLayout); - - var patch = DetailOverrideMigration.MigrateProjectOverride( - XElement.Parse(ShippedLayout), XElement.Parse(PartsXml), _overrideFile, _outputFile); - - Assert.That(patch.IsEmpty, Is.True); - } - } -} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs new file mode 100644 index 0000000000..38782a6a14 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.LCModel; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + public class InventoryViewDefinitionSourceTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private const string PartsXml = @" + + + + +"; + + private string _projectPath; + + public override void TestSetup() + { + base.TestSetup(); + _projectPath = Path.Combine(Path.GetTempPath(), "fw-inventory-source-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectPath); + } + + public override void TestTearDown() + { + base.TestTearDown(); + if (Directory.Exists(_projectPath)) + Directory.Delete(_projectPath, true); + } + + [Test] + public void GetSnapshot_ReturnsShippedLayoutAndMergedParts() + { + const string layoutXml = @" + + + + +"; + var layouts = CreateLayoutInventory(layoutXml); + var parts = CreatePartsInventory(); + var source = CreateSource(layouts, parts); + + var snapshot = source.GetSnapshot("LexEntry", "Normal"); + + Assert.That(snapshot, Is.Not.Null); + Assert.That(XElement.Parse(snapshot.LayoutXml).Element("part")?.Attribute("ref")?.Value, + Is.EqualTo("CitationForm")); + Assert.That(XElement.Parse(snapshot.PartsXml).Descendants("part").Single().Attribute("id")?.Value, + Is.EqualTo("LexEntry-Detail-CitationForm")); + } + + [Test] + public void GetSnapshot_AfterPersistedOverrideReturnsNewXmlWithoutChangingPriorSnapshot() + { + const string layoutXml = @" + + + + +"; + var layouts = CreateLayoutInventory(layoutXml); + var source = CreateSource(layouts, CreatePartsInventory()); + var first = source.GetSnapshot("LexEntry", "Normal"); + var changed = new XmlDocument(); + changed.LoadXml(@" + +"); + + layouts.PersistOverrideElement(changed.DocumentElement); + var second = source.GetSnapshot("LexEntry", "Normal"); + + Assert.That(GetVisibility(first), Is.EqualTo("always")); + Assert.That(GetVisibility(second), Is.EqualTo("never")); + } + + [Test] + public void GetSnapshot_ChoiceGuidUsesExactLayoutThenFallsBackToLayoutWithoutChoiceGuid() + { + const string selectedGuid = "11111111-1111-1111-1111-111111111111"; + const string unknownGuid = "22222222-2222-2222-2222-222222222222"; + const string layoutXml = @" + + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var exact = source.GetSnapshot("LexEntry", "Normal", selectedGuid); + var fallback = source.GetSnapshot("LexEntry", "Normal", unknownGuid); + + Assert.That(XElement.Parse(exact.LayoutXml).Attribute("marker")?.Value, Is.EqualTo("exact")); + Assert.That(XElement.Parse(fallback.LayoutXml).Attribute("marker")?.Value, Is.EqualTo("fallback")); + } + + [Test] + public void GetSnapshot_MissingDerivedLayoutUsesBaseLayoutAndRecordsPartResolutionMap() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + + Assert.That(snapshot, Is.Not.Null); + Assert.That(snapshot.ClassName, Is.EqualTo("MoForm")); + Assert.That(snapshot.BaseClassMap, Is.EquivalentTo(new Dictionary + { + ["MoStemAllomorph"] = "MoForm" + })); + } + + [Test] + public void GetSnapshot_BaseClassMapRejectsMutationThroughDictionaryContract() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + var map = (IDictionary)snapshot.BaseClassMap; + + Assert.That(() => map["MoStemAllomorph"] = "CmObject", + Throws.TypeOf()); + } + + [Test] + public void GetSnapshot_DerivedChoiceFallbackWinsBeforeBaseExactChoice() + { + const string selectedGuid = "11111111-1111-1111-1111-111111111111"; + const string layoutXml = @" + + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal", selectedGuid); + + Assert.That(XElement.Parse(snapshot.LayoutXml).Attribute("marker")?.Value, + Is.EqualTo("derived-fallback")); + } + + [Test] + public void GetSnapshot_NoLayoutInClassHierarchyReturnsNull() + { + const string layoutXml = @" + + +"; + var source = CreateSource(CreateLayoutInventory(layoutXml), CreatePartsInventory()); + + var snapshot = source.GetSnapshot("MoStemAllomorph", "Normal"); + + Assert.That(snapshot, Is.Null); + } + + private InventoryViewDefinitionSource CreateSource(Inventory layouts, Inventory parts) + { + return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, + Cache.MetaDataCacheAccessor); + } + + private Inventory CreateLayoutInventory(string xml) + { + var keyAttributes = new Dictionary + { + ["layout"] = new[] { "class", "type", "name", "choiceGuid" } + }; + var inventory = new Inventory("*.fwlayout", "/LayoutInventory/*", keyAttributes, + "InventoryViewDefinitionSourceTests", _projectPath); + inventory.LoadElements(xml, 0); + return inventory; + } + + private static Inventory CreatePartsInventory() + { + var keyAttributes = new Dictionary + { + ["part"] = new[] { "id" } + }; + var inventory = new Inventory("*Parts.xml", "/PartInventory/bin/*", keyAttributes, + "InventoryViewDefinitionSourceTests", "unused"); + inventory.LoadElements(PartsXml, 0); + return inventory; + } + + private static string GetVisibility(Common.FwAvalonia.ViewDefinition.ViewDefinitionSourceSnapshot snapshot) + { + return XElement.Parse(snapshot.LayoutXml).Element("part")?.Attribute("visibility")?.Value; + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs new file mode 100644 index 0000000000..62054b1b43 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs @@ -0,0 +1,294 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + public class ProjectLayoutCompositionTests : MemoryOnlyBackendProviderTestBase + { + private const string LayoutXml = @" + + + + + + + + +"; + + private const string PartsXml = @" + + + + + + + + + + + + + +"; + + private ILexEntry m_entry; + private ILexSense m_sense; + private string m_projectPath; + + public override void TestSetup() + { + base.TestSetup(); + m_projectPath = Path.Combine(Path.GetTempPath(), + "fw-composer-inventory-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_projectPath); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("source", Cache.DefaultAnalWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(m_sense); + m_sense.Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + }); + } + + public override void TestTearDown() + { + try + { + base.TestTearDown(); + } + finally + { + if (Directory.Exists(m_projectPath)) + Directory.Delete(m_projectPath, true); + } + } + + [Test] + public void Compose_InventoryRootFieldsCarryLayoutContext() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "root-context")); + PersistLayout(layouts); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var rootFields = composed.Model.Fields.Where(field => field.ObjectHvo == m_entry.Hvo).ToList(); + + Assert.That(rootFields, Is.Not.Empty); + Assert.That(rootFields, Has.All.Property("ClassName").EqualTo("LexEntry")); + Assert.That(rootFields, Has.All.Property("LayoutName").EqualTo("Normal")); + } + + [Test] + public void Compose_NestedObjectUsesTheSameInventorySourceAndCarriesLayoutContext() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "nested-source")); + PersistLayout(layouts, includeSenses: true); + PersistSenseLayout(layouts); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var nested = composed.Model.Fields.Single(field => field.ObjectHvo == m_sense.Hvo + && field.Field == "Gloss"); + + Assert.That(nested.Label, Is.EqualTo("Project-only Gloss")); + Assert.That(nested.Values.Any(value => value.Value == "house"), Is.True); + Assert.That(nested.ClassName, Is.EqualTo("LexSense")); + Assert.That(nested.LayoutName, Is.EqualTo("Normal")); + } + + [Test] + public void Compose_MissingNestedProjectLayoutUsesCallerInjectedChildren() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "nested-fallback"), + "" + + ""); + PersistLayout(layouts, includeSenses: true, injectSenseGloss: true); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + var nested = composed.Model.Fields.Single(field => field.ObjectHvo == m_sense.Hvo + && field.Field == "Gloss"); + + Assert.That(nested.Label, Is.EqualTo("Project-only Gloss")); + Assert.That(nested.Values.Any(value => value.Value == "house"), Is.True); + } + + [Test] + public void Compose_InventoryVisibilityOverrideMatchesLegacyShowHiddenBehavior() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "visibility")); + PersistLayout(layouts, citationVisibility: "never"); + var source = CreateSource(layouts); + + var hidden = DetailComposer.Compose(m_entry, Cache, showHiddenFields: false, + source: source.GetSnapshot); + var shown = DetailComposer.Compose(m_entry, Cache, showHiddenFields: true, + source: source.GetSnapshot); + + Assert.That(hidden.Model.Fields.Any(field => field.Field == "CitationForm"), Is.False); + Assert.That(shown.Model.Fields.Any(field => field.Field == "CitationForm"), Is.True); + } + + [Test] + public void Compose_InventoryReorderOverrideChangesSiblingOrder() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "reorder")); + PersistLayout(layouts, reverse: true); + var source = CreateSource(layouts); + + var composed = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + + Assert.That(FieldNames(composed), Is.EqualTo(new[] { "Bibliography", "CitationForm" })); + } + + [Test] + public void Compose_SecondInventoryDoesNotSeeFirstProjectsOverride() + { + var firstLayouts = CreateLayoutInventory(Path.Combine(m_projectPath, "first")); + var secondLayouts = CreateLayoutInventory(Path.Combine(m_projectPath, "second")); + PersistLayout(firstLayouts, reverse: true); + PersistLayout(secondLayouts); + var firstSource = CreateSource(firstLayouts); + var secondSource = CreateSource(secondLayouts); + + var first = DetailComposer.Compose(m_entry, Cache, source: firstSource.GetSnapshot); + var second = DetailComposer.Compose(m_entry, Cache, source: secondSource.GetSnapshot); + + Assert.That(FieldNames(first), Is.EqualTo(new[] { "Bibliography", "CitationForm" })); + Assert.That(FieldNames(second), Is.EqualTo(new[] { "CitationForm", "Bibliography" })); + } + + [Test] + public void Compose_PersistedChangeIsVisibleOnNextCompose() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "refresh")); + var source = CreateSource(layouts); + var before = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + + PersistLayout(layouts, citationVisibility: "never"); + var after = DetailComposer.Compose(m_entry, Cache, source: source.GetSnapshot); + + Assert.That(before.Model.Fields.Any(field => field.Field == "CitationForm"), Is.True); + Assert.That(after.Model.Fields.Any(field => field.Field == "CitationForm"), Is.False); + } + + [Test] + public void CompileForObject_InventoryContentFingerprintReusesAndRefreshesCompiledModel() + { + var layouts = CreateLayoutInventory(Path.Combine(m_projectPath, "fingerprint")); + PersistLayout(layouts); + var source = CreateSource(layouts); + + var first = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + var sameContent = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + + PersistLayout(layouts, citationVisibility: "never"); + var changed = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source.GetSnapshot); + + Assert.That(sameContent, Is.SameAs(first)); + Assert.That(changed, Is.Not.SameAs(first)); + Assert.That(changed.Roots.First().Visibility, Is.EqualTo(ViewVisibility.Never)); + } + + [Test] + public void CompileForObject_ProjectSourceMissingLayoutReturnsNull() + { + ViewDefinitionSourceResolver source = (className, layoutName, choiceGuid) => null; + + var compiled = DetailComposer.CompileForObject(Cache, m_entry, "Normal", source); + + Assert.That(compiled, Is.Null); + } + + [Test] + public void CompileForObject_NoProjectSourceFallsBackToShippedLayout() + { + var compiled = DetailComposer.CompileForObject(Cache, m_entry, "Normal"); + + Assert.That(compiled, Is.Not.Null); + Assert.That(compiled.Roots, Has.Count.GreaterThan(2)); + } + + [Test] + public void CompileForObject_SourceExceptionPropagates() + { + ViewDefinitionSourceResolver source = (className, layoutName, choiceGuid) => + throw new InvalidOperationException("source failed"); + + Assert.That(() => DetailComposer.CompileForObject(Cache, m_entry, "Normal", source), + Throws.TypeOf().With.Message.EqualTo("source failed")); + } + + private InventoryViewDefinitionSource CreateSource(Inventory layouts) + { + var parts = new Inventory("*Parts.xml", "/PartInventory/bin/*", + new Dictionary { ["part"] = new[] { "id" } }, + "ProjectLayoutCompositionTests", "unused"); + parts.LoadElements(PartsXml, 0); + return new InventoryViewDefinitionSource(layouts, parts.Root.OuterXml, + Cache.MetaDataCacheAccessor); + } + + private static Inventory CreateLayoutInventory(string projectPath, + string layoutXml = LayoutXml) + { + var layouts = new Inventory("*.fwlayout", "/LayoutInventory/*", + new Dictionary + { + ["layout"] = new[] { "class", "type", "name", "choiceGuid" } + }, "ProjectLayoutCompositionTests", projectPath); + layouts.LoadElements(layoutXml, 0); + return layouts; + } + + private static void PersistLayout(Inventory layouts, string citationVisibility = "always", + bool reverse = false, bool includeSenses = false, bool injectSenseGloss = false) + { + var first = reverse + ? "" + : ""; + var second = reverse + ? "" + : ""; + var document = new XmlDocument(); + var senses = injectSenseGloss + ? "" + : ""; + document.LoadXml("" + + first + second + (includeSenses ? senses : "") + + ""); + layouts.PersistOverrideElement(document.DocumentElement); + } + + private static void PersistSenseLayout(Inventory layouts) + { + var document = new XmlDocument(); + document.LoadXml("" + + ""); + layouts.PersistOverrideElement(document.DocumentElement); + } + + private static IReadOnlyList FieldNames(ComposedDetail composed) + => composed.Model.Fields.Select(field => field.Field).ToList(); + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs index 10505e6163..ab856676dd 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailCommandAdapterHardeningTests.cs @@ -9,9 +9,11 @@ using System.Reflection; using System.Windows.Forms; using System.Xml; +using System.Xml.Linq; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -211,6 +213,91 @@ public void LabelColumnWidth_IgnoresNonPositiveWidths() Assert.That(read.Invoke(m_view, null), Is.Null, "a negative width must not be persisted"); } + [Test] + public void AvaloniaComposition_UsesProjectLayoutInventoryInitializedByLayoutCache() + { + var expected = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + Assert.That(expected, Is.Not.Null, + "LayoutCache.InitializePartInventories should install the project inventory"); + + var source = GetField(m_view, "m_inventoryViewDefinitionSource"); + + Assert.That(source, Is.Not.Null, + "showing the record should lazily create the project view-definition source"); + Assert.That(GetField(source, "_layouts"), Is.SameAs(expected), + "the host source must use the project-keyed Inventory singleton"); + } + + [Test] + public void ImportedCallerPath_IsCanonicalWhenPartsSkipOrExpandOutput() + { + var parts = new DictionaryPartResolver(XElement.Parse(@" + + + + + + + + +")); + const string layoutXml = @" + + + + +"; + + var model = new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts); + var xml = new XmlDocument(); + xml.LoadXml(layoutXml); + var legacyCaller = xml.SelectSingleNode("/layout/part[@ref='Multiple']"); + + Assert.That(model.Roots.Take(2).Select(node => node.SourceCallerPath), + Is.All.EqualTo("part[1]"), + "every output expanded from one caller must retain the same source identity"); + Assert.That(model.Roots[2].SourceCallerPath, Is.EqualTo("part[2]"), + "a skipped caller must not collapse the source address to the output index"); + Assert.That(LegacyLayoutCallerPath.Get(legacyCaller), Is.EqualTo("part[1]"), + "the XmlNode slice key and XElement importer clones must compute the same identity"); + } + + [Test] + public void ChoosePersistentTargetSliceIndex_UsesCallerPathToDisambiguateDuplicateFields() + { + var candidates = new List + { + new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"), + new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]") + }; + var target = new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[3]"); + + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, target); + + Assert.That(index, Is.EqualTo(1), + "the imported caller path should select the same layout part as the legacy slice key"); + } + + [Test] + public void ChoosePersistentTargetSliceIndex_AmbiguousExactPath_FailsClosed() + { + var target = new RecordEditView.PersistentCommandTargetIdentity( + m_entry.Hvo, "CitationForm", "LexEntry", "Normal", "part[0]"); + var candidates = new List + { + target, + target + }; + + var index = RecordEditView.ChoosePersistentTargetSliceIndex(candidates, target); + + Assert.That(index, Is.EqualTo(-1), + "persistent layout commands require one exact slice and must reject ambiguous matches"); + } + // ---------------------------------------------------------------------------------------- // Bootstrap helpers // ---------------------------------------------------------------------------------------- diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 1539a74cb5..4fff6c169f 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -16,6 +16,7 @@ using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; +using SIL.LCModel.Core.Text; using SIL.LCModel.Infrastructure; using XCore; // Both namespaces above define DataTree; the adapter tests mean the legacy WinForms one. @@ -55,6 +56,10 @@ public class DetailObjectCommandExecutionTests : XWorksAppTestBase private List m_createdObjects; private ILexEntry m_entry; private RecordEditView m_view; + private Inventory m_layouts; + private string m_layoutOverridePath; + private bool m_layoutOverrideExisted; + private byte[] m_layoutOverrideBytes; protected override void Init() { @@ -87,6 +92,16 @@ public void SetUpWindow() // Without it, DataTree.GetTemplateForObjLayout finds a null layout inventory and ShowObject // throws an NRE. This is the same bootstrap the DictionaryConfigurationMigrator tests use. LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, Cache.ProjectId.Path); + m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + var configurationDirectory = LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path); + m_layoutOverridePath = Path.GetFullPath(Path.Combine(configurationDirectory, + "LexEntry.fwlayout")); + Assert.That(Path.GetDirectoryName(m_layoutOverridePath), + Is.EqualTo(Path.GetFullPath(configurationDirectory)).IgnoreCase); + m_layoutOverrideExisted = File.Exists(m_layoutOverridePath); + m_layoutOverrideBytes = m_layoutOverrideExisted + ? File.ReadAllBytes(m_layoutOverridePath) + : null; m_createdObjects = new List(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); @@ -105,6 +120,7 @@ public void SetUpWindow() [TearDown] public void TearDownWindow() { + RestoreLayoutOverride(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyTestData); m_createdObjects = null; m_entry = null; @@ -118,6 +134,117 @@ public void TearDownWindow() } } + [TestCase("CmdAlwaysVisible", "Always visible", "ifdata", "always")] + [TestCase("CmdIfData", "Normally hidden, unless non-empty", "always", "ifdata")] + [TestCase("CmdNormallyHidden", "Normally hidden", "always", "never")] + [TestCase("CmdDataTree-MoveFieldUp", "Move Up", null, "up")] + [TestCase("CmdDataTree-MoveFieldDown", "Move Down", null, "down")] + public void PersistentLayoutCommand_UsesLegacyWriter_PersistsAndRecomposes( + string commandId, string label, string initialVisibility, string expectedChange) + { + if (initialVisibility != null) + PersistCitationVisibility(initialVisibility); + RefreshAvaloniaDetail(); + if (expectedChange == "up") + MoveCitationDownThroughNativeCommand(); + + var beforeModel = GetHostedDetailModel(); + var field = beforeModel.Fields.Single(f => f.Field == "CitationForm"); + var beforeIndex = beforeModel.Fields.ToList().IndexOf(field); + var layoutBefore = CurrentLexEntryLayout().OuterXml; + + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, label); + Assert.That(item, Is.Not.Null, commandId + " should materialize through the native menu"); + Assert.That(item.IsEnabled, Is.True, commandId + " should be enabled for Citation Form"); + item.Execute(); + + var persisted = CurrentLexEntryLayout(); + Assert.That(persisted.OuterXml, Is.Not.EqualTo(layoutBefore), + commandId + " should run the legacy Slice handler and change its Inventory layout"); + Assert.That(File.Exists(m_layoutOverridePath), Is.True, + commandId + " should persist through Inventory to the project .fwlayout file"); + + var afterModel = GetHostedDetailModel(); + Assert.That(afterModel, Is.Not.SameAs(beforeModel), + commandId + " should refresh the Avalonia model from the changed XML"); + if (expectedChange == "never") + { + Assert.That(afterModel.Fields, Has.None.Property("Field").EqualTo("CitationForm")); + } + else if (expectedChange == "up" || expectedChange == "down") + { + var afterIndex = afterModel.Fields.ToList().FindIndex(f => f.Field == "CitationForm"); + Assert.That(Math.Sign(afterIndex - beforeIndex), + Is.EqualTo(expectedChange == "up" ? -1 : 1), + commandId + " should recompose Citation Form in the persisted direction"); + } + else + { + var part = persisted.SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part.Attributes["visibility"].Value, Is.EqualTo(expectedChange)); + Assert.That(afterModel.Fields, Has.Some.Property("Field").EqualTo("CitationForm")); + } + } + + [Test] + public void PersistentLayoutCommand_MissingExactIdentity_ClearsTargetAndDoesNotWrite() + { + PersistCitationVisibility("ifdata"); + RefreshAvaloniaDetail(); + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + field.SourceCallerPath = "part[999]"; + var beforeBytes = File.ReadAllBytes(m_layoutOverridePath); + + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, "Always visible"); + var writingSystems = FindItem(items, "Writing Systems"); + var dataTree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + + Assert.That(item, Is.Not.Null.And.Property("IsEnabled").False); + Assert.That(writingSystems, Is.Not.Null); + Assert.That(FindItem(writingSystems.Children, "Configure...").IsEnabled, Is.True, + "failure to find an exact persistent target must not disable broad legacy commands"); + item.Execute(); + Assert.That(dataTree.CurrentSlice, Is.Null, + "executing a persistent command with no exact identity must clear the legacy target"); + Assert.That(File.ReadAllBytes(m_layoutOverridePath), Is.EqualTo(beforeBytes), + "a disabled persistent command must not invoke the legacy Inventory writer"); + } + + [Test] + public void PersistentMoveCommand_DisabledBeforeExecute_DoesNotWrite() + { + RefreshAvaloniaDetail(); + MoveCitationDownThroughNativeCommand(); + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + var staleItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var staleMoveUp = FindItem(staleItems, "Move Up"); + Assert.That(staleMoveUp, Is.Not.Null.And.Property("IsEnabled").True); + + var currentItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + FindItem(currentItems, "Move Up").Execute(); + field.SourceCallerPath = GetHostedDetailModel().Fields + .Single(f => f.Field == "CitationForm").SourceCallerPath; + var refreshedItems = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + Assert.That(FindItem(refreshedItems, "Move Up").IsEnabled, Is.False, + "returning Citation Form to its first movable position must disable Move Up"); + var beforeBytes = File.ReadAllBytes(m_layoutOverridePath); + var beforeModel = GetHostedDetailModel(); + + staleMoveUp.Execute(); + + Assert.That(File.ReadAllBytes(m_layoutOverridePath), Is.EqualTo(beforeBytes), + "click-time display state must prevent a stale disabled move from reaching the writer"); + Assert.That(GetHostedDetailModel(), Is.SameAs(beforeModel), + "a stale disabled command must return before dispatch and detail recomposition"); + } + // ---------------------------------------------------------------------------------------- // Insert Sense // ---------------------------------------------------------------------------------------- @@ -419,6 +546,16 @@ private IReadOnlyList BuildItems(string[] menuIds) return XCoreMenuBridge.CreateMenuItems(window, menuIds); } + private IReadOnlyList CreateNativeMenuItems(DetailField field, string[] menuIds) + { + var method = typeof(RecordEditView).GetMethod("CreateNativeDetailMenuItems", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, + "the product host should expose its native menu materialization seam"); + return (IReadOnlyList)method.Invoke(m_view, + new object[] { field, menuIds }); + } + private void InvokeItem(IReadOnlyList items, string label) { var item = FindItem(items, label); @@ -473,6 +610,77 @@ private int RefreshedDetailFieldCount() return DetailComposer.Compose(m_entry, Cache).Model.Fields.Count; } + private void RefreshAvaloniaDetail() + { + var refresh = typeof(RecordEditView).GetMethod("RefreshAvaloniaDetail", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(refresh, Is.Not.Null); + refresh.Invoke(m_view, null); + DrainMediatorAndIdleQueues(); + } + + private DetailModel GetHostedDetailModel() + { + var entryForm = (DetailHostControl)GetField(m_view, "m_avaloniaEntryForm"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null); + return tree.Model; + } + + private XmlNode CurrentLexEntryLayout() + { + var layout = m_layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(layout, Is.Not.Null); + return layout; + } + + private void PersistCitationVisibility(string visibility) + { + var changed = CurrentLexEntryLayout().Clone(); + var part = changed.SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part, Is.Not.Null); + var attribute = part.Attributes["visibility"] + ?? changed.OwnerDocument.CreateAttribute("visibility"); + attribute.Value = visibility; + if (attribute.OwnerElement == null) + part.Attributes.Append(attribute); + m_layouts.PersistOverrideElement(changed); + } + + private void MoveCitationDownThroughNativeCommand() + { + var field = GetHostedDetailModel().Fields.Single(f => f.Field == "CitationForm"); + var items = CreateNativeMenuItems(field, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + var item = FindItem(items, "Move Down"); + Assert.That(item, Is.Not.Null.And.Property("IsEnabled").True, + "Move Down must establish a real legacy-slice predecessor for Move Up"); + item.Execute(); + } + + private void RestoreLayoutOverride() + { + if (m_layouts == null || string.IsNullOrEmpty(m_layoutOverridePath)) + return; + if (m_layoutOverrideExisted) + { + Directory.CreateDirectory(Path.GetDirectoryName(m_layoutOverridePath)); + File.WriteAllBytes(m_layoutOverridePath, m_layoutOverrideBytes); + } + else if (File.Exists(m_layoutOverridePath)) + { + File.Delete(m_layoutOverridePath); + } + m_layouts.Reload(); + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(m_layouts)); + } + // ---------------------------------------------------------------------------------------- // Bootstrap helpers (mirrors RecordEditViewActiveHostContractTests) // ---------------------------------------------------------------------------------------- @@ -482,6 +690,8 @@ private void CreateTestEntry() var stemMorphType = GetMorphTypeOrCreateOne("stem"); var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); m_entry = AddLexeme(m_createdObjects, "command-entry", stemMorphType, "first gloss", noun); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("citation", Cache.DefaultVernWs)); } private void AddSense(string gloss) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs new file mode 100644 index 0000000000..20a7b5614e --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs @@ -0,0 +1,651 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.Framework.DetailControls; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using XCore; +using LegacyDataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + [NonParallelizable] + [Apartment(System.Threading.ApartmentState.STA)] + public class LayoutPersistenceParityTests : XWorksAppTestBase + { + private PropertyTable m_propertyTable; + private List m_createdObjects; + private ILexEntry m_entry; + private RecordEditView m_view; + private Inventory m_layouts; + private Inventory m_previousLayouts; + private Inventory m_previousParts; + private bool m_inventoryRegistrationCaptured; + private string m_configurationDirectory; + private string m_configurationBoundary; + private ConfigurationSettingsSnapshot m_configurationSnapshot; + private string m_overridePath; + private string m_originalLayoutXml; + private int m_originalCitationIndex; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, + m_application.DefaultConfigurationPathname); + Cache.ProjectId.Path = Path.Combine(Path.GetTempPath(), Cache.ProjectId.Name, + Cache.ProjectId.Name + ".junk"); + } + + [SetUp] + public void SetUpWindow() + { + m_inventoryRegistrationCaptured = false; + m_configurationSnapshot = null; + m_previousLayouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + m_previousParts = Inventory.GetInventory("parts", Cache.ProjectId.Name); + m_inventoryRegistrationCaptured = true; + try + { + m_configurationDirectory = Path.GetFullPath( + LcmFileHelper.GetConfigSettingsDir(Cache.ProjectId.Path)) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + m_configurationBoundary = m_configurationDirectory + Path.DirectorySeparatorChar; + CaptureConfigurationSettings(); + Assert.That(m_configurationSnapshot, Is.Not.Null); + m_overridePath = Path.GetFullPath(Path.Combine(m_configurationDirectory, + "LexEntry.fwlayout")); + AssertPathIsInConfigurationSettings(m_overridePath); + + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + TestLocalizationManagerBootstrap.EnsureInitialized(); + TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); + if (m_previousLayouts == null || m_previousParts == null) + { + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.Path); + } + m_layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + Assert.That(m_layouts, Is.Not.Null); + Assert.That(Inventory.GetInventory("parts", Cache.ProjectId.Name), Is.Not.Null); + m_originalLayoutXml = CurrentLexEntryLayout().OuterXml; + + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(m_view, Is.Not.Null); + EnsureCurrentRecord(); + Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + m_originalCitationIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); + } + catch + { + try + { + CleanupTestState(false); + } + catch + { + } + throw; + } + } + + [TearDown] + public void TearDownWindow() + { + CleanupTestState(true); + } + + [Test] + public void AvaloniaNormallyHidden_ReloadsIntoAvaloniaAndLegacyDataTree() + { + ResetToNoProjectOverride(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + + ExecuteNative(field, "Normally hidden"); + + Assert.That(GetHostedDetailModel().Fields, + Has.None.Property("Field").EqualTo("CitationForm")); + var modelBeforeReload = GetHostedDetailModel(); + m_layouts.Reload(); + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel(), Is.Not.SameAs(modelBeforeReload)); + Assert.That(GetHostedDetailModel().Fields, + Has.None.Property("Field").EqualTo("CitationForm")); + AssertCitationVisibility("never"); + var legacyTree = LegacyTree(); + legacyTree.RefreshList(true); + var legacyLayouts = (Inventory)GetField(legacyTree, "m_layoutInventory"); + Assert.That(legacyLayouts, Is.SameAs(m_layouts)); + var part = legacyLayouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }) + .SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part.Attributes?["visibility"]?.Value, Is.EqualTo("never")); + Assert.That(legacyTree.Slices, + Has.None.Property("Flid").EqualTo(LexEntryTags.kflidCitationForm)); + } + + [Test] + public void LegacyAlwaysVisible_ReloadsIntoReconstructedAvaloniaHost() + { + ResetToNoProjectOverride(); + var originalView = m_view; + var originalHost = GetField(m_view, "m_avaloniaEntryForm"); + var originalSource = GetField(m_view, "m_inventoryViewDefinitionSource"); + var originalModel = GetHostedDetailModel(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + ExecuteNative(field, "Normally hidden, unless non-empty"); + m_layouts.Reload(); + AssertCitationVisibility("ifdata"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyTree = LegacyTree(); + legacyTree.SetCurrentSliceForCommandTarget(legacyTree.Slices.Single(slice => + slice.Flid == LexEntryTags.kflidCitationForm)); + + ExecuteLegacy("Always visible"); + + m_layouts.Reload(); + AssertCitationVisibility("always"); + ReconstructAvaloniaHost(); + Assert.That(m_view, Is.Not.SameAs(originalView)); + Assert.That(GetField(m_view, "m_avaloniaEntryForm"), Is.Not.SameAs(originalHost)); + Assert.That(GetField(m_view, "m_inventoryViewDefinitionSource"), + Is.Not.SameAs(originalSource)); + Assert.That(GetHostedDetailModel(), Is.Not.SameAs(originalModel)); + Assert.That(GetHostedDetailModel().Fields, + Has.Some.Property("Field").EqualTo("CitationForm")); + } + + [Test] + public void AvaloniaMove_ReloadsInWinFormsOrder() + { + ResetToNoProjectOverride(); + var beforeModel = GetHostedDetailModel(); + var beforeIndex = FieldIndex(beforeModel, "CitationForm"); + var citation = beforeModel.Fields.Single(item => item.Field == "CitationForm"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var beforeLegacyIndex = LegacyTree().Slices.FindIndex(slice => + slice.Flid == LexEntryTags.kflidCitationForm); + + ExecuteNative(citation, "Move Down"); + + var afterAvaloniaIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); + Assert.That(afterAvaloniaIndex, Is.GreaterThan(beforeIndex)); + m_layouts.Reload(); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyIndex = LegacyTree().Slices.FindIndex(slice => + slice.Flid == LexEntryTags.kflidCitationForm); + Assert.That(legacyIndex, Is.GreaterThan(beforeLegacyIndex)); + } + + [Test] + public void WinFormsMove_ReloadsInAvaloniaOrder() + { + ResetToNoProjectOverride(); + var beforeIndex = FieldIndex(GetHostedDetailModel(), "CitationForm"); + EnsureAdapter(m_entry.Hvo, "CitationForm"); + var legacyTree = LegacyTree(); + legacyTree.SetCurrentSliceForCommandTarget(legacyTree.Slices.Single(slice => + slice.Flid == LexEntryTags.kflidCitationForm)); + + ExecuteLegacy("Move Down"); + + m_layouts.Reload(); + RefreshAvaloniaDetail(); + Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), + Is.GreaterThan(beforeIndex)); + } + + [Test] + public void PersistentLayoutCommand_CreatesOnlyProjectFwlayoutArtifact() + { + ResetToNoProjectOverride(); + var filesBefore = ConfigurationFileSnapshot(); + var field = GetHostedDetailModel().Fields.Single(item => item.Field == "CitationForm"); + + ExecuteNative(field, "Normally hidden"); + + var filesAfter = ConfigurationFileSnapshot(); + var created = filesAfter.Keys.Except(filesBefore.Keys, + StringComparer.OrdinalIgnoreCase).ToArray(); + var removed = filesBefore.Keys.Except(filesAfter.Keys, + StringComparer.OrdinalIgnoreCase).ToArray(); + Assert.That(created.Select(path => path.ToUpperInvariant()), + Is.EqualTo(new[] { m_overridePath.ToUpperInvariant() })); + Assert.That(created.Select(Path.GetExtension), Is.All.EqualTo(".fwlayout")); + Assert.That(filesAfter[m_overridePath], Is.Not.Empty); + Assert.That(removed, Is.Empty); + foreach (var file in filesBefore) + { + Assert.That(filesAfter, Does.ContainKey(file.Key)); + Assert.That(filesAfter[file.Key], Is.EqualTo(file.Value)); + } + } + + private void ResetToNoProjectOverride() + { + if (File.Exists(m_overridePath)) + File.Delete(m_overridePath); + m_layouts.Reload(); + Assert.That(File.Exists(m_overridePath), Is.False); + RefreshAvaloniaDetail(); + Assert.That(GetHostedDetailModel().Fields, + Has.Some.Property("Field").EqualTo("CitationForm")); + } + + private void ExecuteNative(DetailField field, string label) + { + var method = typeof(RecordEditView).GetMethod("CreateNativeDetailMenuItems", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + var items = (IReadOnlyList)method.Invoke(m_view, + new object[] { field, new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId } }); + ExecuteItem(items, label); + } + + private void ExecuteLegacy(string label) + { + var window = m_propertyTable.GetValue("window"); + var items = XCoreMenuBridge.CreateMenuItems(window, + new[] { "mnuDataTree-MultiStringSlice", RecordEditView.ObjectMenuId }); + ExecuteItem(items, label); + m_window.Mediator.IdleQueue.Clear(); + } + + private static void ExecuteItem(IReadOnlyList items, string label) + { + var item = FindItem(items, label); + Assert.That(item, Is.Not.Null, "expected the '{0}' command", label); + Assert.That(item.IsEnabled, Is.True, "expected the '{0}' command to be enabled", label); + item.Execute(); + } + + private static DetailMenuItem FindItem(IReadOnlyList items, string label) + { + foreach (var item in items) + { + if (string.Equals(item.Label, label, StringComparison.Ordinal)) + return item; + var nested = FindItem(item.Children, label); + if (nested != null) + return nested; + } + return null; + } + + private void EnsureAdapter(int targetHvo, string fieldName) + { + var method = typeof(RecordEditView).GetMethod("EnsureMenuCommandAdapter", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + method.Invoke(m_view, new object[] { targetHvo, fieldName }); + } + + private void RefreshAvaloniaDetail() + { + var refresh = typeof(RecordEditView).GetMethod("RefreshAvaloniaDetail", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(refresh, Is.Not.Null); + refresh.Invoke(m_view, null); + DrainMediatorAndIdleQueues(); + } + + private DetailModel GetHostedDetailModel() + { + var entryForm = (DetailHostControl)GetField(m_view, "m_avaloniaEntryForm"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null); + return tree.Model; + } + + private LegacyDataTree LegacyTree() + { + var tree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + Assert.That(tree.Slices, Is.Not.Empty); + return tree; + } + + private XmlNode CurrentLexEntryLayout() + { + var layout = m_layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(layout, Is.Not.Null); + return layout; + } + + private void AssertCitationVisibility(string expected) + { + var part = CurrentLexEntryLayout().SelectSingleNode("part[@ref='CitationFormAllV']"); + Assert.That(part, Is.Not.Null); + Assert.That(part.Attributes?["visibility"]?.Value, Is.EqualTo(expected)); + } + + private static int FieldIndex(DetailModel model, string fieldName) + { + return model.Fields.ToList().FindIndex(field => field.Field == fieldName); + } + + private Dictionary ConfigurationFileSnapshot() + { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(m_configurationDirectory)) + return snapshot; + foreach (var path in Directory.GetFiles(m_configurationDirectory, "*", + SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(path); + AssertPathIsInConfigurationSettings(fullPath); + snapshot.Add(fullPath, File.ReadAllBytes(fullPath)); + } + return snapshot; + } + + private void AssertPathIsInConfigurationSettings(string path) + { + var fullPath = Path.GetFullPath(path); + Assert.That(fullPath.StartsWith(m_configurationBoundary, + StringComparison.OrdinalIgnoreCase), Is.True); + } + + private void CaptureConfigurationSettings() + { + var directoryExisted = Directory.Exists(m_configurationDirectory); + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + if (directoryExisted) + { + foreach (var file in ConfigurationFileSnapshot()) + files.Add(RelativeConfigurationPath(file.Key), file.Value); + foreach (var directory in Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories)) + { + var fullPath = Path.GetFullPath(directory); + AssertPathIsInConfigurationSettings(fullPath); + directories.Add(RelativeConfigurationPath(fullPath)); + } + } + m_configurationSnapshot = new ConfigurationSettingsSnapshot(directoryExisted, + files, directories); + } + + private string RelativeConfigurationPath(string path) + { + var fullPath = Path.GetFullPath(path); + if (!fullPath.StartsWith(m_configurationBoundary, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Path is outside ConfigurationSettings: " + fullPath); + return fullPath.Substring(m_configurationBoundary.Length); + } + + private string FullConfigurationPath(string relativePath) + { + if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath)) + throw new InvalidOperationException("ConfigurationSettings path must be relative."); + var fullPath = Path.GetFullPath(Path.Combine(m_configurationDirectory, relativePath)); + AssertPathIsInConfigurationSettings(fullPath); + return fullPath; + } + + private void RestoreConfigurationSettings(bool assertBehavior) + { + if (m_configurationSnapshot == null) + return; + var currentFiles = ConfigurationFileSnapshot(); + foreach (var file in currentFiles.Keys) + { + var relative = RelativeConfigurationPath(file); + if (!m_configurationSnapshot.Files.ContainsKey(relative)) + File.Delete(file); + } + foreach (var file in m_configurationSnapshot.Files) + { + var fullPath = FullConfigurationPath(file.Key); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + File.WriteAllBytes(fullPath, file.Value); + } + RestoreConfigurationDirectories(); + AssertConfigurationSettingsRestored(); + if (m_layouts == null) + return; + m_layouts.Reload(); + if (!assertBehavior || string.IsNullOrEmpty(m_originalLayoutXml)) + return; + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(m_layouts)); + Assert.That(CurrentLexEntryLayout().OuterXml, Is.EqualTo(m_originalLayoutXml)); + if (m_view == null || m_view.IsDisposed) + return; + RefreshAvaloniaDetail(); + Assert.That(FieldIndex(GetHostedDetailModel(), "CitationForm"), + Is.EqualTo(m_originalCitationIndex)); + } + + private void RestoreConfigurationDirectories() + { + if (Directory.Exists(m_configurationDirectory)) + { + var directories = Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories).OrderByDescending(path => path.Length).ToArray(); + foreach (var directory in directories) + { + var fullPath = Path.GetFullPath(directory); + AssertPathIsInConfigurationSettings(fullPath); + if (!m_configurationSnapshot.Directories.Contains( + RelativeConfigurationPath(fullPath)) + && !Directory.EnumerateFileSystemEntries(fullPath).Any()) + { + Directory.Delete(fullPath, false); + } + } + } + foreach (var directory in m_configurationSnapshot.Directories + .OrderBy(path => path.Length)) + Directory.CreateDirectory(FullConfigurationPath(directory)); + if (!m_configurationSnapshot.DirectoryExisted + && Directory.Exists(m_configurationDirectory) + && !Directory.EnumerateFileSystemEntries(m_configurationDirectory).Any()) + { + Directory.Delete(m_configurationDirectory, false); + } + } + + private void AssertConfigurationSettingsRestored() + { + Assert.That(Directory.Exists(m_configurationDirectory), + Is.EqualTo(m_configurationSnapshot.DirectoryExisted)); + if (!m_configurationSnapshot.DirectoryExisted) + return; + var restored = ConfigurationFileSnapshot().ToDictionary( + item => RelativeConfigurationPath(item.Key), item => item.Value, + StringComparer.OrdinalIgnoreCase); + Assert.That(restored.Keys, Is.EquivalentTo(m_configurationSnapshot.Files.Keys)); + foreach (var file in m_configurationSnapshot.Files) + Assert.That(restored[file.Key], Is.EqualTo(file.Value)); + var restoredDirectories = Directory.GetDirectories(m_configurationDirectory, "*", + SearchOption.AllDirectories).Select(RelativeConfigurationPath).ToArray(); + Assert.That(restoredDirectories, + Is.EquivalentTo(m_configurationSnapshot.Directories)); + } + + private void CleanupTestState(bool assertBehavior) + { + Exception firstFailure = null; + CaptureCleanupFailure(() => RestoreConfigurationSettings(assertBehavior), ref firstFailure); + if (m_createdObjects != null) + { + CaptureCleanupFailure(() => NonUndoableUnitOfWorkHelper.Do( + Cache.ActionHandlerAccessor, DestroyTestData), ref firstFailure); + } + CaptureCleanupFailure(() => m_propertyTable?.RemoveLocalAndGlobalSettings(), + ref firstFailure); + if (m_window != null && !m_window.IsDisposed) + CaptureCleanupFailure(() => m_window.Dispose(), ref firstFailure); + if (m_inventoryRegistrationCaptured) + { + CaptureCleanupFailure(() => RestoreInventoryRegistration("layouts", m_previousLayouts), + ref firstFailure); + CaptureCleanupFailure(() => RestoreInventoryRegistration("parts", m_previousParts), + ref firstFailure); + m_inventoryRegistrationCaptured = false; + } + m_createdObjects = null; + m_entry = null; + m_view = null; + m_layouts = null; + m_previousLayouts = null; + m_previousParts = null; + m_propertyTable = null; + m_window = null; + m_overridePath = null; + m_configurationSnapshot = null; + if (firstFailure != null) + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + + private static void CaptureCleanupFailure(Action action, ref Exception firstFailure) + { + try + { + action(); + } + catch (Exception error) + { + if (firstFailure == null) + firstFailure = error; + } + } + + private sealed class ConfigurationSettingsSnapshot + { + internal ConfigurationSettingsSnapshot(bool directoryExisted, + Dictionary files, HashSet directories) + { + DirectoryExisted = directoryExisted; + Files = files; + Directories = directories; + } + + internal bool DirectoryExisted { get; } + + internal Dictionary Files { get; } + + internal HashSet Directories { get; } + } + + private void RestoreInventoryRegistration(string key, Inventory previous) + { + if (ReferenceEquals(Inventory.GetInventory(key, Cache.ProjectId.Name), previous)) + return; + if (previous == null) + Inventory.RemoveInventory(key, Cache.ProjectId.Name); + else + Inventory.SetInventory(key, Cache.ProjectId.Name, previous); + Assert.That(Inventory.GetInventory(key, Cache.ProjectId.Name), Is.SameAs(previous)); + } + + private void CreateTestEntry() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + m_entry = AddLexeme(m_createdObjects, "layout-parity-entry", stemMorphType, + "first gloss", noun); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("citation", Cache.DefaultVernWs)); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("bibliography", Cache.DefaultAnalWs)); + } + + private void DestroyTestData() + { + if (m_createdObjects == null) + return; + foreach (var obj in m_createdObjects) + { + if (obj.IsValidObject && obj is ILexEntry) + obj.Delete(); + } + } + + private void LoadRecordEditView(string toolValue) + { + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + var controlNode = windowConfiguration.SelectSingleNode(string.Format( + "//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", + toolValue)); + Assert.That(controlNode, Is.Not.Null); + m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", toolValue, true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + private void ReconstructAvaloniaHost() + { + var originalView = m_view; + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + var browseControl = windowConfiguration.SelectSingleNode( + "//tool[@value='lexiconBrowse']/control"); + Assert.That(browseControl, Is.Not.Null); + m_propertyTable.SetProperty("currentContentControlParameters", browseControl, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", "lexiconBrowse", true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + DrainMediatorAndIdleQueues(); + Assert.That(originalView.IsDisposed, Is.True); + + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(m_view, Is.Not.Null); + EnsureCurrentRecord(); + Assert.That(GetField(m_view, "m_activeUIFramework"), Is.EqualTo(UIFramework.Avalonia)); + } + + private void EnsureCurrentRecord() + { + if (m_view.Clerk.CurrentObject?.Hvo != m_entry.Hvo) + { + m_view.Clerk.JumpToRecord(m_entry.Hvo); + DrainMediatorAndIdleQueues(); + } + Assert.That(m_view.Clerk.CurrentObject?.Hvo, Is.EqualTo(m_entry.Hvo)); + } + + private static object GetField(object target, string fieldName) + { + var field = target.GetType().GetField(fieldName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "missing private field: " + fieldName); + return field.GetValue(target); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs index 82c1a5a828..0e62b44d81 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/RecordEditViewSwitchTests.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Infrastructure; @@ -44,7 +45,8 @@ public void SetUpWindow() // EnsureDataTreeInitialized (LayoutCache loads the real lexicon .fwlayout/Parts). // Without it, DataTree.GetTemplateForObjLayout finds a null layout inventory and ShowObject // throws an NRE once the idle-queued show actually runs. - LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, Cache.ProjectId.Path); + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, + Cache.ProjectId.ProjectFolder); m_createdObjects = new List(); NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); } @@ -143,6 +145,77 @@ public void LexiconEditTool_FlipNewLegacyNew_TearsDownThenRebuildsAvaloniaEntryF "flipping back to New must rebuild the refresh controller"); } + [Test] + public void LexiconEditTool_PersistedProjectLayoutChange_IsVisibleAfterFrameworkSwitch() + { + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) + as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + Assert.That(GetHostedDetailModel(control).Fields, + Has.Some.Property("Field").EqualTo("CitationForm"), + "precondition: the initial project layout includes Citation Form"); + + var layouts = Inventory.GetInventory("layouts", Cache.ProjectId.Name); + var configurationDirectory = LcmFileHelper.GetConfigSettingsDir( + Cache.ProjectId.ProjectFolder); + var overridePath = GetLexEntryOverridePath(configurationDirectory); + var overrideExisted = File.Exists(overridePath); + var overrideBytes = overrideExisted ? File.ReadAllBytes(overridePath) : null; + var original = layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }).Clone(); + var changed = new XmlDocument(); + changed.LoadXml("" + + ""); + try + { + layouts.PersistOverrideElement(changed.DocumentElement); + + m_propertyTable.SetProperty("UIMode", "Legacy", true); + DrainMediatorAndIdleQueues(); + m_propertyTable.SetProperty("UIMode", "New", true); + DrainMediatorAndIdleQueues(); + EnsureCurrentRecord(control); + + Assert.That(GetHostedDetailModel(control).Fields, + Has.None.Property("Field").EqualTo("CitationForm"), + "the next host composition should read the persisted inventory content"); + } + finally + { + RestoreOverrideFile(configurationDirectory, overridePath, overrideExisted, + overrideBytes); + layouts.Reload(); + } + Assert.That(File.Exists(overridePath), Is.EqualTo(overrideExisted), + "the persistence test should restore the override file's prior existence"); + if (overrideExisted) + { + Assert.That(File.ReadAllBytes(overridePath), Is.EqualTo(overrideBytes), + "the persistence test should restore the override file's exact bytes"); + } + Assert.That(Inventory.GetInventory("layouts", Cache.ProjectId.Name), Is.SameAs(layouts), + "cleanup should retain the Inventory instance held by the live Avalonia source"); + var restored = layouts.GetElement("layout", + new[] { "LexEntry", "detail", "Normal", null }); + Assert.That(restored.OuterXml, Is.EqualTo(original.OuterXml), + "the effective layout inventory should be restored for later tests"); + + m_propertyTable.SetProperty("UIMode", "Legacy", true); + DrainMediatorAndIdleQueues(); + m_propertyTable.SetProperty("UIMode", "New", true); + DrainMediatorAndIdleQueues(); + EnsureCurrentRecord(control); + Assert.That(GetHostedDetailModel(control).Fields, + Has.Some.Property("Field").EqualTo("CitationForm"), + "the retained project source should recompose from the restored inventory"); + } + // Tools not registered for Avalonia fall back to legacy under // New mode. (domainTypeEdit = a Lists CmPossibility tool.) Analyses rides // the interlinear editor's Avalonia work -- see @@ -243,6 +316,47 @@ private static object GetPrivateFieldValue(object target, string fieldName) return field.GetValue(target); } + private static DetailModel GetHostedDetailModel(RecordEditView control) + { + var entryForm = GetPrivateField(control, "m_avaloniaEntryForm"); + Assert.That(entryForm, Is.Not.Null, "the Avalonia detail host should be initialized"); + var hostField = typeof(AvaloniaHostControlBase).GetField("Host", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null, "the host content field should exist"); + var host = hostField.GetValue(entryForm); + var content = host.GetType().GetProperty("Content").GetValue(host, null); + var tree = content as SIL.FieldWorks.Common.FwAvalonia.Detail.DataTree; + Assert.That(tree, Is.Not.Null, "the Avalonia host should contain a detail tree"); + return tree.Model; + } + + private static string GetLexEntryOverridePath(string configurationDirectory) + { + var fullDirectory = Path.GetFullPath(configurationDirectory); + var overridePath = Path.GetFullPath(Path.Combine(fullDirectory, "LexEntry.fwlayout")); + Assert.That(Path.GetDirectoryName(overridePath), + Is.EqualTo(fullDirectory).IgnoreCase, + "the override path must remain inside the fixture ConfigurationSettings directory"); + return overridePath; + } + + private static void RestoreOverrideFile(string configurationDirectory, string overridePath, + bool existed, byte[] bytes) + { + var validatedPath = GetLexEntryOverridePath(configurationDirectory); + Assert.That(overridePath, Is.EqualTo(validatedPath).IgnoreCase, + "cleanup must target the captured LexEntry override path"); + if (existed) + { + Directory.CreateDirectory(configurationDirectory); + File.WriteAllBytes(validatedPath, bytes); + } + else if (File.Exists(validatedPath)) + { + File.Delete(validatedPath); + } + } + // DrainMediatorAndIdleQueues is inherited from XWorksAppTestBase. private void EnsureCurrentRecord(RecordEditView control)