Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions Src/Common/FwAvalonia/FwAvaloniaTests/SliceIdImportTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// A slice's authored <c>id=</c> 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).
/// </summary>
[TestFixture]
public class SliceIdImportTests
{
private const string PartsXml = @"
<PartInventory><bin>
<part id='CmPossibility-Detail-Status'>
<slice id='CmPossibilityStatus' label='Status' editor='string' field='Status'/>
</part>
<part id='CmPossibility-Detail-Name'>
<slice label='Name' editor='string' field='Name'/>
</part>
</bin></PartInventory>";

private static ViewDefinitionModel Import(string layoutXml)
{
var parts = new DictionaryPartResolver(XElement.Parse(PartsXml));
return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts);
}

private static IEnumerable<ViewNode> 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(@"
<layout class='CmPossibility' type='detail' name='default'>
<part ref='Status'/>
<part ref='Name'/>
</layout>");

[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");
}
}
}
11 changes: 10 additions & 1 deletion Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,10 @@ public ViewNode(
ViewStringList enumStringList = null,
IReadOnlyList<string> visibleWritingSystems = null,
bool toggleValue = false,
bool reorder = false)
bool reorder = false,
string sliceId = null)
{
SliceId = sliceId;
Reorder = reorder;
ToggleValue = toggleValue;
VisibleWritingSystems = visibleWritingSystems;
Expand Down Expand Up @@ -430,6 +432,13 @@ public ViewNode(

public ViewNodeKind Kind { get; }

/// <summary>
/// The slice's authored <c>id=</c>, 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
/// <see cref="StableId"/>, which is synthesized and always present.
/// </summary>
public string SliceId { get; }

public string Label { get; }

public string Abbreviation { get; }
Expand Down
7 changes: 5 additions & 2 deletions Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter
public static readonly HashSet<string> HandledSliceAttributes =
new HashSet<string>(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"
};
Expand Down Expand Up @@ -394,7 +394,8 @@ private ViewNode CreateNode(
localizationKey, automationId, routing, boldEmphasis, fontScalePercent,
menuId, contextMenuId, hotlinksId,
chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null,
visibleWritingSystems: visibleWss);
visibleWritingSystems: visibleWss,
sliceId: Attr(contentEl, "id"));
}

// Dynamic custom slices keep their legacy class/assembly identity so the host can
Expand All @@ -411,6 +412,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,
Expand Down
40 changes: 30 additions & 10 deletions Src/xWorks/Avalonia/Composer/DetailComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ public static ComposedDetail Compose(ILexEntry entry, LcmCache cache, bool showH
SlicePluginRegistry plugins = null,
ViewDefinitionOverrideResolver overrides = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
=> Compose((ICmObject)entry, cache, "Normal", showHiddenFields, plugins, overrides,
showAllWritingSystemsFields: showAllWritingSystemsFields,
writingSystemFocused: writingSystemFocused);
writingSystemFocused: writingSystemFocused,
hiddenSliceIds: hiddenSliceIds);

/// <summary>
/// Compose the structured detail view for ANY record root + starting layout -- the
Expand All @@ -141,7 +143,8 @@ public static ComposedDetail Compose(ICmObject obj, LcmCache cache, string layou
ViewDefinitionOverrideResolver overrides = null,
string layoutChoiceField = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
{
if (obj == null) throw new ArgumentNullException(nameof(obj));
if (cache == null) throw new ArgumentNullException(nameof(cache));
Expand All @@ -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);
Expand Down Expand Up @@ -352,8 +355,10 @@ public ComposeState(LcmCache cache, bool showHiddenFields,
SlicePluginRegistry plugins, Func<IDetailEditContext> editContextAccessor,
ViewDefinitionOverrideResolver overrides = null,
ISet<string> showAllWritingSystemsFields = null,
Action<string> writingSystemFocused = null)
Action<string> writingSystemFocused = null,
ISet<string> hiddenSliceIds = null)
{
_hiddenSliceIds = hiddenSliceIds;
_cache = cache;
_showHidden = showHiddenFields;
_plugins = plugins;
Expand Down Expand Up @@ -536,10 +541,9 @@ private ViewDefinitionModel CompileForObjectWithOverrides(ICmObject obj, string
private bool HideWhenEmpty(ViewNode node) => node.Visibility == ViewVisibility.IfData && !_showHidden;

/// <summary>
/// Whether the DOMAIN says this field does not apply to this object, which legacy
/// asks before building a slice (SliceFilter -> ICmObject.IsFieldRelevant). StemName
/// is irrelevant on a clitic or particle, Position on a non-infix, InflectionClasses
/// on some affix forms.
/// Whether the DOMAIN says this field does not apply to this object. StemName is
/// irrelevant on a clitic or particle, Position on a non-infix, InflectionClasses on
/// some affix forms, FromPartsOfSpeech on an entry with no clitic.
///
/// Not the same as hidden: show-hidden-fields does NOT reveal an irrelevant field, so
/// this is checked whatever _showHidden says. Legacy's propsToMonitor set is
Expand Down Expand Up @@ -572,10 +576,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<string> _hiddenSliceIds;

/// <summary>
/// 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.
/// </summary>
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;
}

switch (node.Kind)
{
Expand Down
49 changes: 47 additions & 2 deletions Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ private bool ShouldUseAvaloniaLexiconEdit
get { return m_activeUIFramework == UIFramework.Avalonia; }
}

// Memoized: the tool's configuration cannot change while the view lives.
private ISet<string> m_hiddenSliceIds;

/// <summary>The slice ids this tool's filter list withholds.</summary>
private ISet<string> HiddenSliceIds
=> m_hiddenSliceIds ?? (m_hiddenSliceIds = ReadSliceFilterIds(m_configurationParameters));

/// <summary>
/// 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.
/// </summary>
/// <param name="configuration">The tool's configuration parameters; null yields an empty
/// set.</param>
internal static ISet<string> ReadSliceFilterIds(XmlNode configuration)
{
var ids = new HashSet<string>(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;
}

/// <summary>
/// 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),
Expand Down Expand Up @@ -360,7 +403,8 @@ private void ShowAvaloniaEntry(ICmObject obj)
? DetailComposer.Compose(lexEntry, Cache, showHidden,
overrides: ResolveViewOverride,
showAllWritingSystemsFields: m_showAllWsFields,
writingSystemFocused: OnDetailWritingSystemFocused)
writingSystemFocused: OnDetailWritingSystemFocused,
hiddenSliceIds: HiddenSliceIds)
// 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.
Expand All @@ -369,7 +413,8 @@ private void ShowAvaloniaEntry(ICmObject obj)
overrides: ResolveViewOverride,
layoutChoiceField: m_layoutChoiceField,
showAllWritingSystemsFields: m_showAllWsFields,
writingSystemFocused: OnDetailWritingSystemFocused);
writingSystemFocused: OnDetailWritingSystemFocused,
hiddenSliceIds: HiddenSliceIds);
if (composed != null)
{
detail = composed.Model;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// The composer asks the DOMAIN whether a field applies to an object before emitting a row,
/// which legacy does through SliceFilter -> ICmObject.IsFieldRelevant.
/// through ICmObject.IsFieldRelevant. A tool's own filter list is a separate gate, covered
/// by DetailSliceFilterTests.
///
/// Five classes override it in liblcm. MoStemAllomorph.StemName is covered by
/// AllomorphSectionCompositionTests; the detail-view relevant remainder is covered here.
/// VirtualOrdering also overrides it, but that class is not shown in a detail view, so there
/// is nothing for the composer to gate.
/// AllomorphSectionCompositionTests; VirtualOrdering is not shown in a detail view, so there
/// is nothing to gate. The rest are here, EXCEPT the InflectionClass limb that withholds the
/// row from a compound rule's left/right MSA, which no test reaches.
///
/// Every test runs with showHiddenFields TRUE. Relevance is not a hidden field -- legacy
/// withholds an irrelevant row even with Show Hidden Fields on -- and the flag also keeps an
Expand Down Expand Up @@ -130,6 +131,44 @@ public void Compose_AffixInflectionClasses_OnlyWhenTheEntrySupportsThem()
"now there is something to choose from, so the row composes");
}

/// <summary>
/// MoStemMsa.FromPartsOfSpeech ("Attaches to Categories") is relevant only when the
/// owning entry has a proclitic or enclitic allomorph. The layout declares that part
/// visibility="always", so before the relevance gate it composed on EVERY stem MSA --
/// making this the gate's most visible consequence, and the one with the most rows
/// riding on it.
/// </summary>
[Test]
public void Compose_FromPartsOfSpeech_OnlyForAnEntryWithAClitic()
{
ILexEntry entry = null;
IMoStemMsa msa = null;
NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
{
entry = Cache.ServiceLocator.GetInstance<ILexEntryFactory>().Create();
msa = Cache.ServiceLocator.GetInstance<IMoStemMsaFactory>().Create();
entry.MorphoSyntaxAnalysesOC.Add(msa);
});

var fields = DetailComposer.Compose(entry, Cache, showHiddenFields: true).Model.Fields;
Assert.That(HasRow(fields, "FromPartsOfSpeech", msa.Hvo), Is.False,
"no clitic on the entry, so the domain says the row does not apply");

// The positive control: add a proclitic allomorph, change nothing else.
NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
{
var clitic = Cache.ServiceLocator.GetInstance<IMoStemAllomorphFactory>().Create();
entry.AlternateFormsOS.Add(clitic);
clitic.Form.set_String(Cache.DefaultVernWs,
TsStringUtils.MakeString("clitico", Cache.DefaultVernWs));
clitic.MorphTypeRA = MorphTypes.GetObject(MoMorphTypeTags.kguidMorphProclitic);
});

fields = DetailComposer.Compose(entry, Cache, showHiddenFields: true).Model.Fields;
Assert.That(HasRow(fields, "FromPartsOfSpeech", msa.Hvo), Is.True,
"the same row on the same object composes once the entry has a clitic");
}

/// <summary>
/// MoStemMsa.InflectionClass is irrelevant until a part of speech is chosen -- there is
/// no inflection class to pick without one. This one is NOT an allomorph field; it is the
Expand Down
Loading
Loading