diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs index 1b18330748..52b9615f65 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs @@ -111,7 +111,9 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() { new ViewChooserLink("goto", "Edit the Publications list", "publicationsEdit"), new ViewChooserLink("simple", "Add a slot", "MakeInflAffixSlotChooserCommand", "TopPOS") - }); + }, + helpTopicId: "khtpField-LexEntry-Senses", + sliceId: "CmPossibilityStatus"); var model = new ViewDefinitionModel("LexEntry", "Normal", "detail", new List { node }, new List()); @@ -172,6 +174,10 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() Assert.That(r.ChooserLinks[0].Target, Is.Null, "ChooserLinks[0].Target"); Assert.That(r.ChooserLinks[1].Type, Is.EqualTo("simple"), "ChooserLinks[1].Type"); Assert.That(r.ChooserLinks[1].Target, Is.EqualTo("TopPOS"), "ChooserLinks[1].Target"); + Assert.That(r.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Senses"), nameof(r.HelpTopicId)); + Assert.That(r.SliceId, Is.EqualTo("CmPossibilityStatus"), nameof(r.SliceId)); + Assert.That(r.StableId, Is.Not.EqualTo(r.SliceId), + "both are written as ids, and one must not overwrite the other"); }); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/SliceIdImportTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/SliceIdImportTests.cs new file mode 100644 index 0000000000..97d30f64aa --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/SliceIdImportTests.cs @@ -0,0 +1,84 @@ +// 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 System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// A slice's authored id= is the name a tool's filter list uses to withhold the row. + /// It has to survive import, or the composer has nothing to match a filter against + /// (LT-22802). + /// + [TestFixture] + public class SliceIdImportTests + { + private const string PartsXml = @" + + + + + + + +"; + + private static ViewDefinitionModel Import(string layoutXml) + { + var parts = new DictionaryPartResolver(XElement.Parse(PartsXml)); + return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts); + } + + private static IEnumerable Flatten(ViewNode n) + { + yield return n; + foreach (var c in n.Children) + foreach (var d in Flatten(c)) + yield return d; + } + + private static ViewDefinitionModel BothRows() => Import(@" + + + +"); + + [Test] + public void Slice_WithAnId_CarriesItOntoTheNode() + { + var nodes = BothRows().Roots.SelectMany(Flatten).ToList(); + + Assert.That(nodes.Select(n => n.SliceId), Does.Contain("CmPossibilityStatus"), + "the filter list names rows by this id; dropping it at import leaves the " + + "composer unable to apply the tool's filter at all"); + } + + [Test] + public void Slice_WithoutAnId_LeavesItNull() + { + var name = BothRows().Roots.SelectMany(Flatten) + .Single(n => n.Field == "Name"); + + Assert.That(name.SliceId, Is.Null, + "most slices author no id, and a synthesized one could collide with a real " + + "entry in some tool's filter list"); + } + + [Test] + public void SliceId_IsNotReportedAsAnUnhandledAttribute() + { + var unhandled = BothRows().Diagnostics + .Where(d => d.Code == "unhandled-attribute" && d.Message.Contains("id")) + .ToList(); + + Assert.That(unhandled, Is.Empty, + "id is consumed now, so it must leave the unhandled-attribute report -- that " + + "report is the list of things the Avalonia view still ignores"); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs index 36dd1f4363..fe9b6a6f38 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs @@ -48,8 +48,8 @@ public void Apply_EmptyPatch_ReproducesBaseExactly() } // 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. + // any override exists. These fields are outside ToSnapshot() and aren't covered by the + // EmptyPatch test. [Test] public void Apply_PreservesNodeFieldsNoOperationTouches() { @@ -59,7 +59,8 @@ public void Apply_PreservesNodeFieldsNoOperationTouches() 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))); + enumStringList: options, visibleWritingSystems: writingSystems, toggleValue: true, + helpTopicId: "khtpField-LexEntry-Form", sliceId: "CmPossibilityStatus"))); var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", new[] { @@ -76,6 +77,11 @@ public void Apply_PreservesNodeFieldsNoOperationTouches() 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"); + Assert.That(rebuilt.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Form"), + "an authored help topic survives the rebuild"); + Assert.That(rebuilt.SliceId, Is.EqualTo("CmPossibilityStatus"), + "and so does the slice id a tool's filter list names; losing it would show a " + + "withheld row in every layout carrying an override"); } // The writing-system subset a user picks for one field, recorded against its stable id. @@ -155,6 +161,30 @@ public void Apply_DuplicateNode_CopiesLeafUnderNewId() Assert.That(children[1].Field, Is.EqualTo("F")); } + // A duplicate is the same authored part, so a filter list that withholds the source + // withholds the copy, and its help is the source's. + [Test] + public void Apply_DuplicateNode_KeepsTheSourcesSliceIdAndHelpTopic() + { + var shipped = Model(GroupNode("g", "Group", + new ViewNode("g/a", ViewNodeKind.Field, "A", null, "F", "string", + EditorClassification.Known, "vern", ViewVisibility.Always, + ViewExpansion.NotApplicable, false, null, null, + helpTopicId: "khtpField-LexEntry-Form", sliceId: "CmPossibilityStatus"))); + var patch = new ViewDefinitionOverride("LexEntry", "detail", "jtview", + new[] + { + new ViewOverrideOperation(ViewOverrideOperationKind.DuplicateNode, "g/a-copy", + parentStableId: "g", index: 1, sourceStableId: "g/a") + }, null); + + var copy = ViewDefinitionOverrideApplier.Apply(shipped, patch).Roots[0].Children[1]; + + Assert.That(copy.StableId, Is.EqualTo("g/a-copy"), "precondition: this is the copy"); + Assert.That(copy.SliceId, Is.EqualTo("CmPossibilityStatus")); + Assert.That(copy.HelpTopicId, Is.EqualTo("khtpField-LexEntry-Form")); + } + [Test] public void Apply_DuplicateNode_SourceWithChildren_ReportsDiagnostic_AndDoesNotInsert() { diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs index bffd46bc19..2698e3ea5b 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -90,6 +90,7 @@ private static JObject WriteNode(ViewNode node) AddIfPresent(o, "contextMenu", node.ContextMenuId); AddIfPresent(o, "hotlinks", node.HotlinksId); AddIfPresent(o, "helpTopicID", node.HelpTopicId); + AddIfPresent(o, "sliceId", node.SliceId); AddIfPresent(o, "ghost", node.GhostField); AddIfPresent(o, "ghostWs", node.GhostWs); AddIfPresent(o, "ghostClass", node.GhostClass); @@ -214,7 +215,8 @@ private static ViewNode ReadNode(JToken token) ghostInitMethod: (string)o["ghostInitMethod"], condition: ReadCondition((JObject)o["condition"]), chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(), - helpTopicId: (string)o["helpTopicID"]); + helpTopicId: (string)o["helpTopicID"], + sliceId: (string)o["sliceId"]); } private static T ParseEnum(JObject o, string name, T fallback) where T : struct diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 4e8834e557..920b54228d 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -387,8 +387,10 @@ public ViewNode( IReadOnlyList visibleWritingSystems = null, bool toggleValue = false, bool reorder = false, - string helpTopicId = null) + string helpTopicId = null, + string sliceId = null) { + SliceId = sliceId; HelpTopicId = helpTopicId; Reorder = reorder; ToggleValue = toggleValue; @@ -432,6 +434,13 @@ public ViewNode( public ViewNodeKind Kind { get; } + /// + /// The slice's authored id=, the name a tool's filter list uses to withhold the + /// row. Null on the nodes that author none, which is most of them. NOT + /// , which is synthesized and always present. + /// + public string SliceId { get; } + public string Label { get; } public string Abbreviation { get; } diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs index fb6e5e9484..54c968eaed 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs @@ -299,8 +299,9 @@ void Visit(ViewNode node) return map; } - // Reconstruct an immutable node with the overridden fields, copying every other one. - // Every trailing optional constructor argument must be passed, or that field is stripped. + // Rebuild an immutable node with the overridden fields, copying every other one. Pass + // every trailing optional argument, by name, or that field is stripped or shifted into + // its neighbour. private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string label, IReadOnlyList children, IReadOnlyList visibleWritingSystems) => new ViewNode( @@ -310,7 +311,7 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string 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, visibleWritingSystems, n.ToggleValue, n.Reorder, - n.HelpTopicId); + helpTopicId: n.HelpTopicId, sliceId: n.SliceId); // 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). @@ -322,6 +323,6 @@ private static ViewNode CloneWithId(ViewNode n, string newId) 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, n.Reorder, - n.HelpTopicId); + helpTopicId: n.HelpTopicId, sliceId: n.SliceId); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index c43b5b5298..ba3b06c352 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -34,7 +34,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter public static readonly HashSet HandledSliceAttributes = new HashSet(System.StringComparer.Ordinal) { - "label", "abbr", "field", "ws", "editor", "visibility", "expansion", + "id", "label", "abbr", "field", "ws", "editor", "visibility", "expansion", "localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks", "forVariant", "visibleWritingSystems", "reorder", "helpTopicID" }; @@ -395,6 +395,7 @@ private ViewNode CreateNode( menuId, contextMenuId, hotlinksId, chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, visibleWritingSystems: visibleWss, + sliceId: Attr(contentEl, "id"), helpTopicId: Attr(contentEl, "helpTopicID")); } @@ -412,6 +413,8 @@ private ViewNode CreateNode( chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, enumStringList: enumStringList, visibleWritingSystems: visibleWss, + // A tool's filter list withholds rows by this authored id. + sliceId: Attr(contentEl, "id"), // 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, diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 41fefe1049..0ae621e60a 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -117,10 +117,12 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH SlicePluginRegistry plugins = null, ViewDefinitionOverrideResolver overrides = null, ISet showAllWritingSystemsFields = null, - Action writingSystemFocused = null) + Action writingSystemFocused = null, + ISet hiddenSliceIds = null) => Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides, showAllWritingSystemsFields: showAllWritingSystemsFields, - writingSystemFocused: writingSystemFocused); + writingSystemFocused: writingSystemFocused, + hiddenSliceIds: hiddenSliceIds); /// /// Compose the structured detail view for ANY record root + starting layout -- the @@ -141,7 +143,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou ViewDefinitionOverrideResolver overrides = null, string layoutChoiceField = null, ISet showAllWritingSystemsFields = null, - Action writingSystemFocused = null) + Action writingSystemFocused = null, + ISet hiddenSliceIds = null) { if (obj == null) throw new ArgumentNullException(nameof(obj)); if (cache == null) throw new ArgumentNullException(nameof(cache)); @@ -162,7 +165,7 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou IDetailEditContext composedContext = null; var state = new ComposeState(cache, showHiddenFields, plugins ?? SlicePluginRegistry.Default, () => composedContext, overrides, - showAllWritingSystemsFields, writingSystemFocused); + showAllWritingSystemsFields, writingSystemFocused, hiddenSliceIds); state.EnterModel(root); foreach (var node in root.Roots) state.Walk(node, obj, 0); @@ -352,8 +355,10 @@ public ComposeState(LcmCache cache, bool showHiddenFields, SlicePluginRegistry plugins, Func editContextAccessor, ViewDefinitionOverrideResolver overrides = null, ISet showAllWritingSystemsFields = null, - Action writingSystemFocused = null) + Action writingSystemFocused = null, + ISet hiddenSliceIds = null) { + _hiddenSliceIds = hiddenSliceIds; _cache = cache; _showHidden = showHiddenFields; _plugins = plugins; @@ -572,8 +577,9 @@ private readonly HashSet> _propsToMonitor /// irrelevant on a clitic or particle, Position on a non-infix, InflectionClasses on /// some affix forms, FromPartsOfSpeech on an entry with no clitic. /// - /// The relevance gate of the two SliceFilter.IncludeSlice applies. The other - /// looks the slice's id up in the tool's filter list, and LT-22802 covers it. + /// The relevance gate of the two SliceFilter.IncludeSlice applies. The other, + /// , looks the slice's id up in the tool's + /// filter list. /// /// Not the same as hidden: show-hidden-fields leaves an irrelevant field withheld, /// so this is asked whatever _showHidden says. @@ -601,10 +607,26 @@ private bool IsIrrelevantForObject(ViewNode node, ICmObject obj) return !obj.IsFieldRelevant(flid, _propsToMonitor); } + // The tool's filter list, by authored slice id; null when the tool configures none. + private readonly ISet _hiddenSliceIds; + + /// + /// Whether the TOOL withholds this row: a tool's configuration can name slice ids + /// to leave out, and a node carrying one of them is dropped. Checked before the + /// node kind is dispatched, so a withheld node takes its subtree with it. + /// + private bool IsFilteredOutByTool(ViewNode node) + => _hiddenSliceIds != null + && !string.IsNullOrEmpty(node?.SliceId) + && _hiddenSliceIds.Contains(node.SliceId); + public void Walk(ViewNode node, ICmObject obj, int depth) { - if (IsHidden(node) || depth > MaxDepth || IsIrrelevantForObject(node, obj)) + if (IsHidden(node) || depth > MaxDepth || IsFilteredOutByTool(node) + || IsIrrelevantForObject(node, obj)) + { return; + } // Rows added while this node walks are stamped with its help-topic inputs. _walkNodes.Push(node); diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 890000d728..ab87bd433b 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -86,6 +86,50 @@ private bool ShouldUseAvaloniaLexiconEdit get { return m_activeUIFramework == UIFramework.Avalonia; } } + // Memoized, a failed read included: a missing filter file is an install fault, and + // re-reading it would log the failure for every record shown. + private ISet m_hiddenSliceIds; + + /// The slice ids this tool's filter list withholds. + private ISet HiddenSliceIds + => m_hiddenSliceIds ?? (m_hiddenSliceIds = ReadSliceFilterIds(m_configurationParameters)); + + /// + /// The slice ids named by the filter list a tool's configuration points at through its + /// filterPath. Empty for a configuration that names none, and empty when the file cannot + /// be read: a detail view showing an extra row beats one that will not open. + /// + /// The tool's configuration parameters; null yields an empty + /// set. + internal static ISet ReadSliceFilterIds(XmlNode configuration) + { + var ids = new HashSet(StringComparer.Ordinal); + try + { + var filterPath = XmlUtils.GetOptionalAttributeValue(configuration, "filterPath"); + if (string.IsNullOrEmpty(filterPath)) + return ids; + if (!Platform.IsWindows) + filterPath = filterPath.Replace(@"\", "/"); + + var document = new XmlDocument(); + document.Load(FwDirectoryFinder.GetCodeFile(filterPath)); + foreach (XmlNode node in document.SelectNodes("SliceFilter/node")) + { + var id = XmlUtils.GetOptionalAttributeValue(node, "id"); + if (!string.IsNullOrEmpty(id)) + ids.Add(id); + } + } + catch (Exception e) + { + Logger.WriteError("Reading the tool's slice filter failed; no row is withheld " + + "by it.", e); + } + + return ids; + } + /// /// Auto-save: settles any open fenced edit session -- commit when validation is /// clean, roll back otherwise. The holder guards internally (no-op when nothing is open), @@ -305,6 +349,31 @@ private void ScheduleOnUiThread(Action runner) } } + /// + /// The detail this view composes for , under the tool's own + /// configuration: its layout, its view overrides and its slice filter list. + /// + internal ComposedDetail ComposeDetail(ICmObject obj, bool showHidden) + { + var lexEntry = obj as ILexEntry; + return lexEntry != null + ? DetailComposer.Compose(lexEntry, Cache, showHidden, + overrides: ResolveViewOverride, + showAllWritingSystemsFields: m_showAllWsFields, + writingSystemFocused: OnDetailWritingSystemFocused, + hiddenSliceIds: HiddenSliceIds) + // Other roots use the tool's layout (m_layoutName, default "Normal"); + // a type-selected one, such as RnGenericRec keyed on "Type", resolves + // inside Compose. + : DetailComposer.Compose(obj, Cache, + string.IsNullOrEmpty(m_layoutName) ? "Normal" : m_layoutName, showHidden, + overrides: ResolveViewOverride, + layoutChoiceField: m_layoutChoiceField, + showAllWritingSystemsFields: m_showAllWsFields, + writingSystemFocused: OnDetailWritingSystemFocused, + hiddenSliceIds: HiddenSliceIds); + } + /// /// Shows the Avalonia detail view for a record: the composed full-entry view when the record is a /// lexical entry (first-slice fallback if composition fails), or the resource-backed @@ -357,20 +426,7 @@ private void ShowAvaloniaEntry(ICmObject obj) ComposedDetail composed = null; try { - composed = lexEntry != null - ? DetailComposer.Compose(lexEntry, Cache, showHidden, - overrides: ResolveViewOverride, - showAllWritingSystemsFields: m_showAllWsFields, - writingSystemFocused: OnDetailWritingSystemFocused) - // 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, - layoutChoiceField: m_layoutChoiceField, - showAllWritingSystemsFields: m_showAllWsFields, - writingSystemFocused: OnDetailWritingSystemFocused); + composed = ComposeDetail(obj, showHidden); if (composed != null) { detail = composed.Model; diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailFieldRelevanceTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailFieldRelevanceTests.cs index 313c68ca22..3380b61858 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailFieldRelevanceTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailFieldRelevanceTests.cs @@ -13,7 +13,8 @@ namespace SIL.FieldWorks.XWorks /// /// The composer asks the DOMAIN whether a field applies to an object before emitting a row, /// through ICmObject.IsFieldRelevant -- the relevance gate of the two - /// SliceFilter.IncludeSlice applies, the id/filter-list one being LT-22802. + /// SliceFilter.IncludeSlice applies. The tool's filter list is the other, covered by + /// DetailSliceFilterTests. /// /// Five classes override it in liblcm. MoStemAllomorph.StemName is covered by /// AllomorphSectionCompositionTests; VirtualOrdering is not shown in a detail view, so there diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSliceFilterTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSliceFilterTests.cs new file mode 100644 index 0000000000..4c346768a8 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSliceFilterTests.cs @@ -0,0 +1,97 @@ +// 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.Detail; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The TOOL's gate on a row: a slice whose authored id appears in the filter list the + /// tool's filterPath names is withheld, whatever the domain says about it (LT-22802). The + /// domain's own gate is separate, and is covered by DetailFieldRelevanceTests. + /// + /// Composed against CmPossibility because that is the only class any shipped filter list + /// reaches: the five CmPossibility slice ids in basicPlusFilter.xml are the only entries + /// across the six filter files that name a slice the parts inventory defines. + /// + [TestFixture] + public class DetailSliceFilterTests : MemoryOnlyBackendProviderTestBase + { + private ICmPossibility m_possibility; + + [SetUp] + public void CreateProductionRestriction() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var list = Cache.LangProject.MorphologicalDataOA.ProdRestrictOA; + m_possibility = Cache.ServiceLocator.GetInstance() + .Create(); + list.PossibilitiesOS.Add(m_possibility); + m_possibility.Name.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("restriction", Cache.DefaultAnalWs)); + }); + } + + private IReadOnlyList Compose(params string[] hiddenSliceIds) + => DetailComposer.Compose(m_possibility, Cache, "default", showHiddenFields: true, + hiddenSliceIds: hiddenSliceIds.Length == 0 + ? null + : new HashSet(hiddenSliceIds)) + .Model.Fields; + + [Test] + public void AFilteredSliceId_WithholdsThatRow_AndNothingElse() + { + var unfiltered = Compose(); + Assume.That(unfiltered.Any(f => f.Field == "Status"), Is.True, + "fixture check: unfiltered, the Status row composes"); + + var filtered = Compose("CmPossibilityStatus"); + + Assert.That(filtered.Any(f => f.Field == "Status"), Is.False, + "the tool's filter list names this row, so it is withheld"); + Assert.That(filtered.Count, Is.EqualTo(unfiltered.Count - 1), + "and ONLY that row: a withheld node must not take unrelated rows with it"); + } + + /// + /// A whole shipped filter list at once: every id it names goes, and the rows it does + /// not name stay. + /// + [Test] + public void AWholeFilterList_WithholdsEveryIdItNames_AndNothingElse() + { + var filtered = Compose("CmPossibilityStatus", "CmPossibilityDiscussion", + "CmPossibilityConfidence", "CmPossibilityResearchers", "CmPossibilityRestrictions"); + var fields = filtered.Select(f => f.Field).ToList(); + + Assert.That(fields, Has.No.Member("Status").And.No.Member("Discussion") + .And.No.Member("Confidence").And.No.Member("Researchers") + .And.No.Member("Restrictions"), + "every id the filter lists is withheld. Composed:\n " + + string.Join("\n ", fields)); + Assert.That(fields, Does.Contain("Name").And.Contains("Abbreviation"), + "and the rows the filter does not name are untouched"); + } + + [Test] + public void AnIdInNoFilterList_LeavesEveryRowAlone() + { + var unfiltered = Compose(); + + var filtered = Compose("NotAnIdAnySliceAuthors"); + + Assert.That(filtered.Count, Is.EqualTo(unfiltered.Count), + "a filter naming nothing withholds nothing -- most shipped filter entries are " + + "stale ids that resolve to no slice, and they must stay harmless"); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index 279709f03c..5f52eb2905 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -318,6 +318,91 @@ public void AnEnvironmentItem_ResolvesToTheMenuThatCarriesItsCommands() } } + /// + /// A tool's filter list reaches the composer through the view. The shipped Exception + /// "Features" configuration (ProdRestrictEdit) names basicPlusFilter.xml, which withholds + /// Status; composing through the view's own configuration, rather than a hand-built id + /// set, is what shows the file, the property and the composer are joined. + /// + [Test] + public void AToolsFilterList_ReachesTheComposer_ThroughTheView() + { + ICmPossibility restriction = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + restriction = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.MorphologicalDataOA.ProdRestrictOA.PossibilitiesOS.Add(restriction); + restriction.Name.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("restriction", Cache.DefaultAnalWs)); + }); + var original = SwapToolConfiguration(null); + try + { + var shipped = ProdRestrictEditParameters(); + Assert.That(shipped.Attributes["filterPath"], Is.Not.Null, + "precondition: the shipped configuration names a filter list"); + + var unfiltered = (XmlElement)shipped.Clone(); + unfiltered.RemoveAttribute("filterPath"); + SwapToolConfiguration(unfiltered); + Assert.That(ComposedFields(restriction), Does.Contain("Status"), + "control: without the filter list, the same view composes the Status row"); + + SwapToolConfiguration(shipped); + Assert.That(ComposedFields(restriction), Does.Not.Contain("Status"), + "the view's filter list reached the composer and withheld the row"); + } + finally + { + SwapToolConfiguration(original); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + if (restriction != null && restriction.IsValidObject) + restriction.Delete(); + }); + } + } + + private IReadOnlyList ComposedFields(ICmObject obj) + => m_view.ComposeDetail(obj, showHidden: true).Model.Fields.Select(f => f.Field).ToList(); + + // The record-edit parameters of the shipped ProdRestrictEdit tool: the node that carries + // its filterPath and its layout. + private static XmlElement ProdRestrictEditParameters() + { + var document = new XmlDocument(); + document.Load(Path.Combine(FwDirectoryFinder.CodeDirectory, "Language Explorer", + "Configuration", "Grammar", "Edit", "toolConfiguration.xml")); + var node = (XmlElement)document.SelectSingleNode( + "//tool[@value='ProdRestrictEdit']//parameters[@filterPath]"); + Assert.That(node, Is.Not.Null, "precondition: the shipped ProdRestrictEdit parameters"); + return node; + } + + // Installs a tool configuration on the view the way ReadParameters does, layout + // included, and clears the memoized filter list. Returns the one it replaced; null + // changes nothing. + private XmlNode SwapToolConfiguration(XmlNode configuration) + { + var parameters = typeof(XCoreUserControl).GetField("m_configurationParameters", + BindingFlags.Instance | BindingFlags.NonPublic); + var layout = typeof(RecordEditView).GetField("m_layoutName", + BindingFlags.Instance | BindingFlags.NonPublic); + var memo = typeof(RecordEditView).GetField("m_hiddenSliceIds", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(parameters, Is.Not.Null); + Assert.That(layout, Is.Not.Null); + Assert.That(memo, Is.Not.Null); + var previous = (XmlNode)parameters.GetValue(m_view); + if (configuration != null) + { + parameters.SetValue(m_view, configuration); + layout.SetValue(m_view, configuration.Attributes?["layout"]?.Value); + memo.SetValue(m_view, null); + } + return previous; + } + [Test] public void SubentriesCtrlClick_ResolvesTheClickedEntry_AndRunsTheDefaultJumpPath() { diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/SliceFilterListReadingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/SliceFilterListReadingTests.cs new file mode 100644 index 0000000000..617ed27de4 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/SliceFilterListReadingTests.cs @@ -0,0 +1,76 @@ +// 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.Xml; +using NUnit.Framework; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Reading a tool's filter list off its configuration: the filterPath attribute names a file + /// under the code directory, and each of its SliceFilter/node ids becomes a row the composer + /// withholds. A missing path, file or configuration yields no ids. + /// + [TestFixture] + public class SliceFilterListReadingTests + { + // The filterPath Grammar's Category Edit and Exception "Features" both configure. + private const string ShippedFilterPath = + @"Language Explorer\Configuration\Grammar\Edit\DataEntryFilters\basicPlusFilter.xml"; + + private static XmlNode Configuration(string attributes) + { + var document = new XmlDocument(); + document.LoadXml(""); + return document.DocumentElement; + } + + /// + /// The whole chain against a real shipped filter file: the attribute name, the path + /// resolution, the XPath and the id attribute. Asserts CONTAINMENT, so editing that file + /// does not break the test, but breaking any link in the chain does. + /// + [Test] + public void AConfiguredFilterPath_YieldsTheIdsThatFileNames() + { + var ids = RecordEditView.ReadSliceFilterIds( + Configuration(@"filterPath=""" + ShippedFilterPath + @"""")); + + Assert.That(ids, Does.Contain("CmPossibilityStatus"), + "the ids the file names must come back, or the composer is handed an empty set " + + "and withholds nothing while every other test still passes"); + Assert.That(ids, Does.Contain("CmPossibilityDiscussion") + .And.Contains("CmPossibilityConfidence") + .And.Contains("CmPossibilityResearchers") + .And.Contains("CmPossibilityRestrictions")); + } + + [Test] + public void AConfigurationWithNoFilterPath_YieldsNoIds() + { + var ids = RecordEditView.ReadSliceFilterIds(Configuration(@"clerk=""entries""")); + + Assert.That(ids, Is.Empty, + "most tools configure no filter, and they must withhold nothing"); + } + + [Test] + public void AFilterPathThatResolvesToNothing_YieldsNoIds_WithoutThrowing() + { + ISet ids = null; + + Assert.DoesNotThrow(() => ids = RecordEditView.ReadSliceFilterIds( + Configuration(@"filterPath=""no\such\filter.xml""")), + "an unreadable filter must not stop the detail view opening"); + Assert.That(ids, Is.Empty); + } + + [Test] + public void ANullConfiguration_YieldsNoIds() + { + Assert.That(RecordEditView.ReadSliceFilterIds(null), Is.Empty); + } + } +}