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