diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs
index f18c135229..ac6fd04098 100644
--- a/Src/Common/FwAvalonia/Detail/DataTree.cs
+++ b/Src/Common/FwAvalonia/Detail/DataTree.cs
@@ -752,8 +752,8 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
Control header;
if (field.IsCollapsible)
{
- var button = new DetailSectionHeader(headerLabel, IsExpanded(field),
- _toggleableHeaders.Contains(row));
+ var button = new DetailSectionHeader(headerLabel, field.CollapsedLabel,
+ IsExpanded(field), _toggleableHeaders.Contains(row));
button.Click += (s, e) => ToggleSection(field, button);
shown.Header = button;
header = button;
@@ -766,12 +766,12 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// A null background only hit-tests the glyphs; the whole header area must
// take the right-click.
label.Background = FwAvaloniaDensity.TransparentBrush;
+ AutomationProperties.SetName(label, field.Label ?? string.Empty);
header = label;
}
header.Margin = new Thickness(indent.Left, 4, 0, FwAvaloniaDensity.FieldSpacing);
AutomationProperties.SetAutomationId(header, automationId);
- AutomationProperties.SetName(header, field.Label ?? string.Empty);
// The header answers right-click with its slice menu; the hover
// "..." field-menu button (in a thin gutter to the left of the header)
// opens the section menu/hotlinks.
@@ -786,11 +786,10 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// through the existing host bridge identically.
var hotlinkStrip = CreateHotlinkStrip(field, automationId, indent);
- // Top-level sections get the heavy-weight separator rule. The header cell and
- // its hotlink strip travel together: the strip is part of the header row,
- // hidden and shown with it.
+ // Top-level sections get the heavy rule unless the layout gives them normal
+ // weight. The hotlink strip is part of the header row, shown and hidden with it.
Control headerControl;
- if (field.Indent == 0 && row > 0)
+ if (field.Indent == 0 && row > 0 && !field.IsNormalWeight)
{
var withRule = new StackPanel();
withRule.Children.Add(new Border
diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs
index 7443fef167..5a98a5ae81 100644
--- a/Src/Common/FwAvalonia/Detail/DetailModel.cs
+++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs
@@ -1702,6 +1702,18 @@ public string ExpansionKey
set => _expansionKey = value;
}
+ ///
+ /// The text a collapsible header shows while collapsed; null when it shows
+ /// in both states.
+ ///
+ public string CollapsedLabel { get; set; }
+
+ ///
+ /// True for a header whose layout gives it normal rather than heavy weight (a
+ /// NormalSummary row); it gets no heavy section rule even at the top level.
+ ///
+ public bool IsNormalWeight { get; set; }
+
/// Legacy slice menu id (layout `menu=`) for right-click on the row/label (13.x).
public string MenuId { get; }
diff --git a/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs b/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs
index 779fe3f47b..464d3f2346 100644
--- a/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs
+++ b/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs
@@ -26,12 +26,21 @@ public sealed class DetailSectionHeader : Button
{
private DetailSectionHeaderAutomationPeer _peer;
private bool _isExpanded;
+ private readonly string _label;
+ private readonly string _collapsedLabel;
+ private readonly TextBlock _text;
- /// Creates a header showing .
- public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
+ ///
+ /// Creates a header showing while expanded and
+ /// while collapsed; a null
+ /// shows in both states.
+ ///
+ public DetailSectionHeader(string label, string collapsedLabel, bool isExpanded, bool canToggle)
{
CanToggle = canToggle;
_isExpanded = isExpanded;
+ _label = label ?? string.Empty;
+ _collapsedLabel = collapsedLabel ?? _label;
Box = new DetailExpanderBox(isExpanded) { Opacity = canToggle ? 1 : 0 };
Background = FwAvaloniaDensity.TransparentBrush;
BorderThickness = new Thickness(0);
@@ -39,16 +48,14 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
// A section header hugs the left edge.
HorizontalAlignment = HorizontalAlignment.Left;
HorizontalContentAlignment = HorizontalAlignment.Left;
- var text = CreateLabel(label);
- text.VerticalAlignment = VerticalAlignment.Center;
- Content = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Spacing = FwAvaloniaDensity.TreeBoxGap,
- Children = { Box, text }
- };
+ _text = CreateLabel(CurrentLabel);
+ AutomationProperties.SetName(this, CurrentLabel);
+ Content = new BoxAndLabelPanel { Children = { Box, _text } };
}
+ /// The text shown for the current state.
+ public string CurrentLabel => _isExpanded ? _label : _collapsedLabel;
+
///
/// The section header label text, in the section header size, colour and bold weight,
/// for a header that cannot collapse as well as inside this one.
@@ -58,9 +65,49 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
Text = label ?? string.Empty,
FontSize = FwAvaloniaDensity.SectionHeaderFontSize,
Foreground = FwAvaloniaDensity.SectionHeaderBrush,
- FontWeight = FontWeight.Bold
+ FontWeight = FontWeight.Bold,
+ // Long lines wrap to the pane width, as the WinForms summary does.
+ TextWrapping = TextWrapping.Wrap
};
+ ///
+ /// Lays out the +/- box and the label side by side: the label wraps in the width left
+ /// after the box, and the box sits centred on the label's first line.
+ ///
+ private sealed class BoxAndLabelPanel : Panel
+ {
+ private Control BoxChild => Children[0];
+ private TextBlock LabelChild => (TextBlock)Children[1];
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ BoxChild.Measure(Size.Infinity);
+ var boxWidth = BoxChild.DesiredSize.Width + FwAvaloniaDensity.TreeBoxGap;
+ LabelChild.Measure(new Size(Math.Max(0, availableSize.Width - boxWidth), availableSize.Height));
+ return new Size(boxWidth + LabelChild.DesiredSize.Width,
+ Math.Max(BoxChild.DesiredSize.Height, LabelChild.DesiredSize.Height));
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var box = BoxChild.DesiredSize;
+ var boxWidth = box.Width + FwAvaloniaDensity.TreeBoxGap;
+ var label = LabelChild.DesiredSize;
+ var firstLine = FirstLineHeight(LabelChild, label.Height);
+ var lineTop = Math.Max(0, (box.Height - firstLine) / 2);
+ BoxChild.Arrange(new Rect(0, Math.Max(0, (firstLine - box.Height) / 2), box.Width, box.Height));
+ LabelChild.Arrange(new Rect(boxWidth, lineTop, Math.Max(0, finalSize.Width - boxWidth), label.Height));
+ return finalSize;
+ }
+
+ // The height of the label's first line, or the whole label when it has no lines yet.
+ private static double FirstLineHeight(TextBlock label, double whole)
+ {
+ var lines = label.TextLayout.TextLines;
+ return lines.Count > 0 ? lines[0].Height : whole;
+ }
+ }
+
/// Themed as a plain .
protected override Type StyleKeyOverride => typeof(Button);
@@ -68,8 +115,9 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
public bool CanToggle { get; }
///
- /// Whether the rows under the header are shown. Setting it redraws the +/- box and tells
- /// assistive technology the state changed; it does not show or hide any rows.
+ /// Whether the rows under the header are shown. Setting it redraws the +/- box, shows
+ /// the label for the new state, and tells assistive technology the state changed; it
+ /// does not show or hide any rows.
///
public bool IsExpanded
{
@@ -81,6 +129,8 @@ public bool IsExpanded
var old = ExpandCollapseState;
_isExpanded = value;
Box.IsExpanded = value;
+ _text.Text = CurrentLabel;
+ AutomationProperties.SetName(this, CurrentLabel);
_peer?.RaiseExpandCollapseStateChanged(old, ExpandCollapseState);
}
}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
index d6192ff1a1..b82d78caca 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
@@ -16,6 +16,7 @@
using Avalonia.VisualTree;
using FwAvaloniaTests.VisualChecks;
using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
@@ -342,6 +343,68 @@ public void SectionHeaderAndFieldOptionsText_TakeTheirOwnSizeAndColour()
"the link is not the button theme's semibold");
}
+ // LT-22689: a header with a collapsed line (a sense, etymology, extended note or
+ // subrecord) shows it while collapsed and its own label while expanded.
+ [AvaloniaTest]
+ public void CollapsibleHeader_ShowsItsCollapsedLine_OnlyWhileCollapsed()
+ {
+ var sense = Header("sense", "Sense 1", 0);
+ sense.CollapsedLabel = "Sense 1 - house - n";
+ var note = Header("note", "Extended Note", 0, expanded: false);
+ note.CollapsedLabel = "Extended Note - use - formal";
+ var view = Show(sense, Text("g1", "Gloss", 1), note, Text("d1", "Discussion", 1));
+ string Shown(string id) => SectionHeader(view, id).GetVisualDescendants().OfType().Single().Text;
+
+ Assert.That(Shown("sense"), Is.EqualTo("Sense 1"));
+ Assert.That(Shown("note"), Is.EqualTo("Extended Note - use - formal"),
+ "a header that starts collapsed shows its collapsed line");
+
+ ClickHeader(SectionHeader(view, "sense"));
+ Assert.That(Shown("sense"), Is.EqualTo("Sense 1 - house - n"));
+ Assert.That(AutomationProperties.GetName(SectionHeader(view, "sense")), Is.EqualTo("Sense 1 - house - n"),
+ "assistive technology reads the line that is shown");
+
+ ClickHeader(SectionHeader(view, "sense"));
+ Assert.That(Shown("sense"), Is.EqualTo("Sense 1"));
+ }
+
+ // A collapsed sense line with a sentence-long definition wraps to the pane, as the
+ // WinForms summary does, with the +/- box beside its first line.
+ [AvaloniaTest]
+ public void LongHeaderLine_WrapsWithinThePane_WithTheBoxBesideItsFirstLine()
+ {
+ var longLine = "Sense 1 - " + string.Concat(Enumerable.Repeat("a long definition word ", 12)) + "- n";
+ var sense = Header("sense", "Sense 1", 0, expanded: false);
+ sense.CollapsedLabel = longLine;
+ var view = Show(Header("short", "Sense 2", 0), Text("g2", "Gloss", 1), sense, Text("g1", "Gloss", 1));
+ var header = SectionHeader(view, "sense");
+ var text = header.GetVisualDescendants().OfType().Single();
+ var oneLine = SectionHeader(view, "short").GetVisualDescendants().OfType().Single();
+
+ Assert.That(text.Text, Is.EqualTo(longLine), "precondition: the collapsed line is shown");
+ Assert.That(text.Bounds.Height, Is.GreaterThan(oneLine.Bounds.Height * 1.5), "the line wraps");
+ var right = text.TranslatePoint(new Point(text.Bounds.Width, 0), view).Value.X;
+ Assert.That(right, Is.LessThanOrEqualTo(view.Bounds.Width), "nothing runs past the pane");
+ var boxMiddle = header.Box.TranslatePoint(new Point(0, header.Box.Bounds.Height / 2), text).Value.Y;
+ Assert.That(boxMiddle, Is.LessThan(oneLine.Bounds.Height), "the box sits beside the first line");
+ }
+
+ // A NormalSummary header (an etymology, an extended note) at the top level draws no
+ // heavy section rule; a HeavySummary one (a sense) does.
+ [AvaloniaTest]
+ public void NormalWeightHeader_DrawsNoHeavyRule_AtTheTopLevel()
+ {
+ var etymology = Header("etymology", "Etymology", 0);
+ etymology.IsNormalWeight = true;
+ var view = Show(Header("sense1", "Sense 1", 0), Text("g1", "Gloss", 1),
+ Header("sense2", "Sense 2", 0), Text("g2", "Gloss", 1), etymology, Text("e1", "Gloss", 1));
+ int RulesAbove(string id) => SectionHeader(view, id).GetVisualAncestors().OfType()
+ .SelectMany(p => p.Children.OfType()).Count(b => b.Background == FwAvaloniaDensity.SectionRuleBrush);
+
+ Assert.That(RulesAbove("sense2"), Is.EqualTo(1), "precondition: a top-level sense gets the heavy rule");
+ Assert.That(RulesAbove("etymology"), Is.EqualTo(0));
+ }
+
[AvaloniaTest]
public void HeaderWithNothingUnderIt_DrawsNoBox_AndIgnoresClicks()
{
diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs
index 020999c926..f4b1736146 100644
--- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs
+++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs
@@ -275,6 +275,7 @@ private sealed class ComposeState
private readonly ISilDataAccess _sda;
private readonly IFwMetaDataCacheManaged _mdc;
private readonly HashSet<(int hvo, string layout)> _visited = new HashSet<(int, string)>();
+ private readonly DetailSummaryLines _summaryLines;
public readonly List Fields = new List();
// One edit handler per composed field, keyed by StableId. A field's text/rich-text/option/
@@ -369,6 +370,7 @@ public ComposeState(LcmCache cache, bool showHiddenFields,
_showAllWsFields = showAllWritingSystemsFields;
_sda = cache.DomainDataByFlid;
_mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache;
+ _summaryLines = new DetailSummaryLines(cache);
}
// Track which compiled model the walk is currently inside so each emitted
@@ -3433,6 +3435,12 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
var expanded = node.Expansion != ViewExpansion.Collapsed;
var isSenses = node.Field == "Senses";
var sectionLabel = Localize(node.Label) ?? node.Field;
+ var firstItem = _cache.ServiceLocator.ObjectRepository.GetObject(_sda.get_VecItem(obj.Hvo, flid, 0));
+
+ // Items with a summary line pair (senses, etymologies, extended notes) are
+ // sections of their own: each item's header carries its summary lines, and no
+ // banner sits over them.
+ var summarized = DetailSummaryLines.Covers(firstItem);
// A sequence sitting directly under a section header that already names it (the
// Allomorphs/Variants shape: a summary slice wrapping an with one seq)
@@ -3440,15 +3448,16 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
// item rows their own layouts already label. Both are duplicates, and with no
// label authored on the part ref or the the banner falls back to the raw
// model field name, which bypasses StringTable and cannot be translated.
- var flatten = !isSenses
+ var flatten = !summarized
&& string.IsNullOrEmpty(node.Label)
&& FollowsEnclosingHeader(depth)
&& ItemRootIsLabelled(node, obj, flid, count);
- // Nested sense sequences (Senses on a sense) don't repeat the section banner; the
- // numbered items carry it.
- if (!flatten && !(isSenses && obj is ILexSense))
+ if (!flatten && !summarized)
AddHeader(node, obj, depth, sectionLabel);
+ // Summarized items sit at the sequence's own level; subsenses indent under their
+ // sense.
+ var itemDepth = summarized && !(isSenses && obj is ILexSense) ? depth : depth + 1;
for (var i = 0; i < count; i++)
{
@@ -3461,19 +3470,10 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
continue;
}
- string itemLabel;
- if (isSenses && item is ILexSense sense)
- {
- // Legacy sense numbering: 1, 2, ... and 1.1 for subsenses, with the sense's
- // summary text (ShortName = gloss) in the header line. The number
- // is the domain's own LexSenseOutline (the dictionary/bulk-edit outline), so
- // the entry pane cannot diverge from the other views.
- itemLabel = ($"{sense.LexSenseOutline.Text} {item.ShortName}").TrimEnd();
- }
- else
- {
+ string itemLabel = null;
+ string collapsedLabel = null;
+ if (!summarized || !_summaryLines.TryGet(item, out itemLabel, out collapsedLabel))
itemLabel = $"{sectionLabel} {i + 1}";
- }
// The item's own layout carries the slice menu (e.g. the sense's
// HeavySummary part ref binds mnuDataTree-Sense in LexSense.fwlayout) -- the
@@ -3484,15 +3484,17 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
AddField(new DetailField($"{StableId(node, obj)}/item{i}",
itemLabel, node.Field, null, DetailFieldKind.Header,
EditorClassification.GroupingNone, null, null, HostRouting.Inherit,
- null, null, null, isEditable: false, indent: depth + 1,
+ null, null, null, isEditable: false, indent: itemDepth,
isCollapsible: true, isInitiallyExpanded: expanded,
menuId: itemBinding.MenuId ?? node.MenuId,
hotlinksId: itemBinding.HotlinksId ?? node.HotlinksId,
objectHvo: item.Hvo)
{
- ExpansionKey = ExpansionKey(node.StableId + "/item", item)
+ ExpansionKey = ExpansionKey(node.StableId + "/item", item),
+ CollapsedLabel = collapsedLabel,
+ IsNormalWeight = summarized && DetailSummaryLines.IsNormalWeight(item)
});
- DescendInto(node, item, depth + 1);
+ DescendInto(node, item, itemDepth);
}
}
diff --git a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs
new file mode 100644
index 0000000000..bee5d9f1d1
--- /dev/null
+++ b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs
@@ -0,0 +1,131 @@
+// 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 SIL.FieldWorks.Common.FwUtils;
+using SIL.LCModel;
+using SIL.LCModel.Core.WritingSystems;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// The header lines of the objects whose detail layout opens with a summary row carrying a
+ /// collapsed variant: a sense, an etymology and an extended note. Each pair follows that
+ /// class's Summary and SummaryCollapsed parts in the shipped configuration
+ /// (LexSenseParts.xml, LexEntryParts.xml), with their literal text localized through the
+ /// string table.
+ ///
+ internal sealed class DetailSummaryLines
+ {
+ private readonly LcmCache _cache;
+
+ /// Creates the line builder for 's project.
+ public DetailSummaryLines(LcmCache cache)
+ {
+ _cache = cache;
+ }
+
+ /// True when 's class has a summary line pair.
+ public static bool Covers(ICmObject obj)
+ => obj is ILexSense || obj is ILexEtymology || obj is ILexExtendedNote;
+
+ ///
+ /// True when 's layout opens with a NormalSummary row rather than
+ /// a HeavySummary one: an etymology or an extended note, not a sense.
+ ///
+ public static bool IsNormalWeight(ICmObject obj) => obj is ILexEtymology || obj is ILexExtendedNote;
+
+ ///
+ /// The header text of while expanded and while collapsed. Returns
+ /// false, with both null, when its class has no summary line pair.
+ ///
+ public bool TryGet(ICmObject obj, out string expanded, out string collapsed)
+ {
+ switch (obj)
+ {
+ case ILexSense sense:
+ expanded = Lit("Sense ") + sense.LexSenseOutline?.Text;
+ collapsed = expanded + Lit(" - ") + DefinitionOrGloss(sense) + Lit(" - ")
+ + GrammaticalInfo(sense.MorphoSyntaxAnalysisRA);
+ return true;
+ case ILexEtymology etymology:
+ expanded = Lit("Etymology");
+ collapsed = expanded + Lit(" - ") + (AllEmpty(etymology.LanguageNotes)
+ ? BestAnalysis(etymology.Gloss)
+ : BestAnalysis(etymology.LanguageNotes) + Lit(" - ") + BestAnalysis(etymology.Gloss));
+ return true;
+ case ILexExtendedNote note:
+ expanded = Lit("Extended Note");
+ collapsed = expanded + Lit(" - ") + Abbreviation(note.ExtendedNoteTypeRA) + Lit(" - ")
+ + BestAnalysis(note.Discussion);
+ return true;
+ default:
+ expanded = collapsed = null;
+ return false;
+ }
+ }
+
+ // The definition, or the gloss when the definition is empty in every analysis
+ // writing system.
+ private string DefinitionOrGloss(ILexSense sense)
+ => AllEmpty(sense.Definition) ? BestAnalysis(sense.Gloss) : BestAnalysis(sense.Definition);
+
+ // The MorphoSyntaxAnalysis "Basic" layout of each analysis class.
+ private string GrammaticalInfo(IMoMorphSynAnalysis msa)
+ {
+ switch (msa)
+ {
+ case IMoStemMsa stem:
+ return Abbreviation(stem.PartOfSpeechRA);
+ case IMoUnclassifiedAffixMsa unclassified:
+ return Abbreviation(unclassified.PartOfSpeechRA);
+ case IMoDerivAffMsa derivational:
+ if (derivational.FromPartOfSpeechRA == null)
+ {
+ return derivational.ToPartOfSpeechRA == null
+ ? Lit("Unclassified affix")
+ : Lit("???") + Lit(" > ") + Abbreviation(derivational.ToPartOfSpeechRA);
+ }
+ return Abbreviation(derivational.FromPartOfSpeechRA) + Lit(" > ")
+ + (derivational.ToPartOfSpeechRA == null
+ ? Lit("???.")
+ : Abbreviation(derivational.ToPartOfSpeechRA));
+ case IMoInflAffMsa inflectional:
+ if (inflectional.PartOfSpeechRA == null)
+ return Lit("Unmk. infl. afx slot");
+ return Abbreviation(inflectional.PartOfSpeechRA) + ":"
+ + string.Join("/", inflectional.SlotsRC.Select(slot => slot.ShortName));
+ default:
+ return string.Empty;
+ }
+ }
+
+ private string Abbreviation(ICmPossibility possibility)
+ => possibility == null ? string.Empty : BestAnalysis(possibility.Abbreviation);
+
+ private IEnumerable AnalysisWritingSystems
+ => _cache.LangProject.CurrentAnalysisWritingSystems;
+
+ // The first non-empty alternative in the analysis writing systems, in project order.
+ private string BestAnalysis(IMultiAccessorBase value) => Best(value, AnalysisWritingSystems);
+
+ private static string Best(IMultiAccessorBase value, IEnumerable writingSystems)
+ {
+ if (value == null)
+ return string.Empty;
+ foreach (var ws in writingSystems)
+ {
+ var text = value.get_String(ws.Handle)?.Text;
+ if (!string.IsNullOrEmpty(text))
+ return text;
+ }
+ return string.Empty;
+ }
+
+ private bool AllEmpty(IMultiAccessorBase value) => Best(value, AnalysisWritingSystems).Length == 0;
+
+ private static string Lit(string text) => StringTable.Table.LocalizeLiteralValue(text);
+ }
+}
diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs
index 09e914f471..1a2d3f3585 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs
@@ -451,8 +451,10 @@ public void Compose_WalksTheFullCompiledLayout_AcrossObjects()
"each sense's gloss binds its own object");
Assert.That(glossFields.All(f => f.Indent > 0), Is.True, "sense fields are indented");
- Assert.That(fields.Any(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses"), Is.True,
- "the senses sequence renders a section header");
+ var senseHeaderObjects = fields.Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses")
+ .Select(f => f.ObjectHvo).ToList();
+ Assert.That(senseHeaderObjects, Is.EquivalentTo(m_entry.SensesOS.Select(s => s.Hvo)),
+ "each sense renders its own header, with no section header over them");
}
[Test]
@@ -1329,27 +1331,55 @@ public void Compose_BooleanFields_RenderAsEditableCheckbox()
"the closed enum combo over the same field stays out of scope");
}
+ // LT-22689: as in the WinForms DataTree, no "Senses" row sits over an entry's senses.
+ // Each sense's header sits at the entry's own level, and a subsense's indents under it.
[Test]
- public void Compose_SenseHeaders_UseHierarchicalNumbering_WithGlossSummary()
+ public void Compose_Senses_HaveNoBanner_AndSubsensesIndentUnderTheirSense()
{
+ ILexSense subsense = null;
NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
{
- var sub = Cache.ServiceLocator.GetInstance().Create();
+ subsense = Cache.ServiceLocator.GetInstance().Create();
+ m_entry.SensesOS[0].SensesOS.Add(subsense);
+ subsense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("hut", Cache.DefaultAnalWs));
+ });
+
+ var senseHeaders = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses").ToList();
+
+ Assert.That(senseHeaders.Any(h => h.ObjectHvo == m_entry.Hvo), Is.False,
+ "no section header over the entry's senses");
+ var senseHeader = senseHeaders.Single(h => h.ObjectHvo == m_entry.SensesOS[0].Hvo);
+ var subsenseHeader = senseHeaders.Single(h => h.ObjectHvo == subsense.Hvo);
+ Assert.That(senseHeader.Indent, Is.EqualTo(0), "a sense header sits at the entry's own level");
+ Assert.That(subsenseHeader.Indent, Is.GreaterThan(senseHeader.Indent),
+ "a subsense header indents under its sense");
+ }
+
+ // LT-22689: sense headers read "Sense 1" and "Sense 1.1" while expanded, and add the
+ // gloss and grammatical info while collapsed, as the WinForms sense summary does.
+ [Test]
+ public void Compose_SenseHeaders_UseHierarchicalNumbers_AndTheGlossWhileCollapsed()
+ {
+ ILexSense sub = null;
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ sub = Cache.ServiceLocator.GetInstance().Create();
m_entry.SensesOS[0].SensesOS.Add(sub);
sub.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("subgloss", Cache.DefaultAnalWs));
});
- var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields;
- var headers = fields.Where(f => f.Kind == DetailFieldKind.Header).Select(f => f.Label).ToList();
-
- Assert.That(headers.Any(h => h.StartsWith("1 ") || h.StartsWith("1 ") || h.StartsWith("1 ", System.StringComparison.Ordinal) || h.StartsWith("1")), Is.True,
- "top senses are numbered: " + string.Join(" | ", headers));
- Assert.That(headers.Any(h => h.StartsWith("1.1")), Is.True,
- "subsenses use hierarchical numbers (1.1): " + string.Join(" | ", headers));
- Assert.That(headers.Any(h => h.Contains("gloss 1")), Is.True,
- "sense headers carry the gloss summary like legacy sense lines");
- Assert.That(fields.First(f => f.Label != null && f.Label.StartsWith("1.1")).IsCollapsible, Is.True,
- "sense items are collapsible");
+ var senseHeaders = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses").ToList();
+ var top = senseHeaders.Single(h => h.ObjectHvo == m_entry.SensesOS[0].Hvo);
+ var nested = senseHeaders.Single(h => h.ObjectHvo == sub.Hvo);
+ var gloss = m_entry.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text;
+
+ Assert.That(top.Label, Is.EqualTo("Sense 1"));
+ Assert.That(nested.Label, Is.EqualTo("Sense 1.1"));
+ Assert.That(top.CollapsedLabel, Does.StartWith("Sense 1 - " + gloss + " - "));
+ Assert.That(nested.CollapsedLabel, Does.StartWith("Sense 1.1 - subgloss - "));
+ Assert.That(nested.IsCollapsible, Is.True);
}
[Test]
diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs
new file mode 100644
index 0000000000..689018f41f
--- /dev/null
+++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs
@@ -0,0 +1,231 @@
+// 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.Detail;
+using SIL.LCModel;
+using SIL.LCModel.Core.KernelInterfaces;
+using SIL.LCModel.Core.Text;
+using SIL.LCModel.Infrastructure;
+
+namespace SIL.FieldWorks.XWorks
+{
+ ///
+ /// The expanded and collapsed header lines of senses, etymologies, extended notes and
+ /// notebook records (LT-22689), checked against the WinForms Summary and SummaryCollapsed
+ /// layouts they follow, and where the composer places those headers.
+ ///
+ [TestFixture]
+ public class DetailSummaryLinesTests : MemoryOnlyBackendProviderTestBase
+ {
+ private ILexEntry m_entry;
+ private ILexSense m_sense;
+
+ public override void TestSetup()
+ {
+ base.TestSetup();
+ NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () =>
+ {
+ m_entry = Cache.ServiceLocator.GetInstance().Create();
+ var morph = Cache.ServiceLocator.GetInstance().Create();
+ m_entry.LexemeFormOA = morph;
+ morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs));
+ m_sense = Cache.ServiceLocator.GetInstance().Create();
+ m_entry.SensesOS.Add(m_sense);
+ m_sense.Gloss.set_String(Cache.DefaultAnalWs, Analysis("house"));
+ });
+ }
+
+ private ITsString Analysis(string text) => TsStringUtils.MakeString(text, Cache.DefaultAnalWs);
+
+ private void Do(System.Action action) => NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => action());
+
+ private (string Expanded, string Collapsed) Lines(ICmObject obj)
+ {
+ Assert.That(new DetailSummaryLines(Cache).TryGet(obj, out var expanded, out var collapsed), Is.True);
+ return (expanded, collapsed);
+ }
+
+ private IPartOfSpeech PartOfSpeech(string abbreviation)
+ {
+ if (Cache.LangProject.PartsOfSpeechOA == null)
+ Cache.LangProject.PartsOfSpeechOA = Cache.ServiceLocator.GetInstance().Create();
+ var pos = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(pos);
+ pos.Abbreviation.set_String(Cache.DefaultAnalWs, Analysis(abbreviation));
+ return pos;
+ }
+
+ private void SetGrammaticalInfo(IMoMorphSynAnalysis msa)
+ {
+ m_entry.MorphoSyntaxAnalysesOC.Add(msa);
+ m_sense.MorphoSyntaxAnalysisRA = msa;
+ }
+
+ private string Collapsed(System.Action arrange)
+ {
+ Do(() => arrange(m_entry, m_sense));
+ return Lines(m_sense).Collapsed;
+ }
+
+ [Test]
+ public void Sense_ShowsItsNumber_AndWhileCollapsed_TheGlossAndPartOfSpeech()
+ {
+ Do(() =>
+ {
+ var stem = Cache.ServiceLocator.GetInstance().Create();
+ SetGrammaticalInfo(stem);
+ stem.PartOfSpeechRA = PartOfSpeech("n");
+ });
+
+ var lines = Lines(m_sense);
+
+ Assert.That(lines.Expanded, Is.EqualTo("Sense 1"));
+ Assert.That(lines.Collapsed, Is.EqualTo("Sense 1 - house - n"));
+ }
+
+ [Test]
+ public void Sense_WhileCollapsed_ShowsTheDefinitionInPlaceOfTheGloss()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ sense.Definition.set_String(Cache.DefaultAnalWs, Analysis("a dwelling")));
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - a dwelling - "));
+ }
+
+ [Test]
+ public void GrammaticalInfo_OfADerivationalAffix_ShowsFromAndToPartsOfSpeech()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ {
+ var msa = Cache.ServiceLocator.GetInstance().Create();
+ SetGrammaticalInfo(msa);
+ msa.FromPartOfSpeechRA = PartOfSpeech("v");
+ msa.ToPartOfSpeechRA = PartOfSpeech("n");
+ });
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - house - v > n"));
+ }
+
+ [Test]
+ public void GrammaticalInfo_OfADerivationalAffix_WithNoPartsOfSpeech_IsUnclassified()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ SetGrammaticalInfo(Cache.ServiceLocator.GetInstance().Create()));
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - house - Unclassified affix"));
+ }
+
+ [Test]
+ public void GrammaticalInfo_OfAnInflectionalAffix_ShowsItsPartOfSpeechAndSlots()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ {
+ var pos = PartOfSpeech("v");
+ var slot = Cache.ServiceLocator.GetInstance().Create();
+ pos.AffixSlotsOC.Add(slot);
+ slot.Name.set_String(Cache.DefaultAnalWs, Analysis("tense"));
+ var msa = Cache.ServiceLocator.GetInstance().Create();
+ SetGrammaticalInfo(msa);
+ msa.PartOfSpeechRA = pos;
+ msa.SlotsRC.Add(slot);
+ });
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - house - v:tense"));
+ }
+
+ [Test]
+ public void GrammaticalInfo_OfAnInflectionalAffix_WithNoPartOfSpeech_IsAnUnmarkedSlot()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ SetGrammaticalInfo(Cache.ServiceLocator.GetInstance().Create()));
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - house - Unmk. infl. afx slot"));
+ }
+
+ [Test]
+ public void GrammaticalInfo_OfAnUnclassifiedAffix_ShowsItsPartOfSpeech()
+ {
+ var collapsed = Collapsed((entry, sense) =>
+ {
+ var msa = Cache.ServiceLocator.GetInstance().Create();
+ SetGrammaticalInfo(msa);
+ msa.PartOfSpeechRA = PartOfSpeech("adj");
+ });
+
+ Assert.That(collapsed, Is.EqualTo("Sense 1 - house - adj"));
+ }
+
+ [Test]
+ public void Etymology_WhileCollapsed_ShowsTheGloss_AndAnyLanguageNotesBeforeIt()
+ {
+ ILexEtymology etymology = null;
+ Do(() =>
+ {
+ etymology = Cache.ServiceLocator.GetInstance().Create();
+ m_entry.EtymologyOS.Add(etymology);
+ etymology.Gloss.set_String(Cache.DefaultAnalWs, Analysis("borrowed"));
+ });
+ Assert.That(Lines(etymology), Is.EqualTo(("Etymology", "Etymology - borrowed")));
+
+ Do(() => etymology.LanguageNotes.set_String(Cache.DefaultAnalWs, Analysis("from Latin")));
+ Assert.That(Lines(etymology).Collapsed, Is.EqualTo("Etymology - from Latin - borrowed"));
+ }
+
+ [Test]
+ public void ExtendedNote_WhileCollapsed_ShowsItsTypeAndDiscussion()
+ {
+ ILexExtendedNote note = null;
+ Do(() =>
+ {
+ if (Cache.LangProject.LexDbOA.ExtendedNoteTypesOA == null)
+ Cache.LangProject.LexDbOA.ExtendedNoteTypesOA =
+ Cache.ServiceLocator.GetInstance().Create();
+ var type = Cache.ServiceLocator.GetInstance().Create();
+ Cache.LangProject.LexDbOA.ExtendedNoteTypesOA.PossibilitiesOS.Add(type);
+ type.Abbreviation.set_String(Cache.DefaultAnalWs, Analysis("use"));
+ note = Cache.ServiceLocator.GetInstance().Create();
+ m_sense.ExtendedNoteOS.Add(note);
+ note.ExtendedNoteTypeRA = type;
+ note.Discussion.set_String(Cache.DefaultAnalWs, Analysis("formal speech"));
+ });
+
+ Assert.That(Lines(note), Is.EqualTo(("Extended Note", "Extended Note - use - formal speech")));
+ }
+
+ [Test]
+ public void Compose_EtymologiesAndExtendedNotes_HaveNoBanner_AndCarryTheirSummaryLines()
+ {
+ ILexEtymology etymology = null;
+ ILexExtendedNote note = null;
+ Do(() =>
+ {
+ etymology = Cache.ServiceLocator.GetInstance().Create();
+ m_entry.EtymologyOS.Add(etymology);
+ etymology.Gloss.set_String(Cache.DefaultAnalWs, Analysis("borrowed"));
+ note = Cache.ServiceLocator.GetInstance().Create();
+ m_sense.ExtendedNoteOS.Add(note);
+ note.Discussion.set_String(Cache.DefaultAnalWs, Analysis("formal speech"));
+ });
+
+ var headers = DetailComposer.Compose(m_entry, Cache).Model.Fields
+ .Where(f => f.Kind == DetailFieldKind.Header).ToList();
+
+ Assert.That(headers.Any(h => h.Field == "Etymology" && h.ObjectHvo == m_entry.Hvo), Is.False,
+ "no banner over the etymologies");
+ Assert.That(headers.Any(h => h.Field == "ExtendedNote" && h.ObjectHvo == m_sense.Hvo), Is.False,
+ "no banner over the extended notes");
+ var etymologyHeader = headers.Single(h => h.ObjectHvo == etymology.Hvo);
+ Assert.That((etymologyHeader.Label, etymologyHeader.CollapsedLabel), Is.EqualTo(Lines(etymology)));
+ Assert.That(etymologyHeader.Indent, Is.EqualTo(0), "an etymology header sits at the entry's level");
+ var noteHeader = headers.Single(h => h.ObjectHvo == note.Hvo);
+ Assert.That((noteHeader.Label, noteHeader.CollapsedLabel), Is.EqualTo(Lines(note)));
+ Assert.That(etymologyHeader.IsNormalWeight && noteHeader.IsNormalWeight, Is.True,
+ "etymologies and extended notes open with a NormalSummary row");
+ Assert.That(headers.Single(h => h.ObjectHvo == m_sense.Hvo).IsNormalWeight, Is.False,
+ "a sense opens with a HeavySummary row");
+ }
+ }
+}
diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs
index 430d64dee9..21873ad052 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs
@@ -42,7 +42,7 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase
// Lexeme Form, Citation Form and variant form rows.
"mnuDataTree-LexemeForm", "mnuDataTree-LexemeFormContext", "mnuDataTree-CitationFormContext",
"mnuDataTree-VariantFormContext",
- // Lexicon rows: the Subsenses and Extended Notes section rows, and the hotlinks of
+ // Lexicon rows: the Subsenses and Extended Notes prompt rows, and the hotlinks of
// a sense, an extended note and an etymology.
"mnuDataTree-Subsenses", "mnuDataTree-ExtendedNotes", "mnuDataTree-Sense-Hotlinks",
"mnuDataTree-ExtendedNote-Hotlinks", "mnuDataTree-Etymology-Hotlinks",
@@ -74,8 +74,8 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase
private static readonly string[] LexiconLabelMenuIds =
{
"mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec", "mnuDataTree-LexemeForm",
- "mnuDataTree-Subsenses", "mnuDataTree-ExtendedNotes", "mnuDataTree-AlternateForms",
- "mnuDataTree-VariantForms", "mnuDataTree-Examples"
+ "mnuDataTree-Subsenses", "mnuDataTree-AlternateForms", "mnuDataTree-VariantForms",
+ "mnuDataTree-Examples"
};
private static readonly string[] LexiconInStringMenuIds =
@@ -294,8 +294,8 @@ public void OwnedMenus_AreBuiltWithoutTheAdapterOrTheMediator()
{
Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree yet");
var rows = OwnedMenuRows();
- Assert.That(rows, Has.Count.EqualTo(18),
- "precondition: sixteen ids, -Translation and -VariantForms on two rows each");
+ Assert.That(rows, Has.Count.EqualTo(17),
+ "precondition: fifteen ids, -Translation and -VariantForms on two rows each");
foreach (var (row, menuId, kind) in rows)
{
var ids = kind == DetailMenuKind.ContextMenu
@@ -416,7 +416,7 @@ public void InsertLeaf_IsOfferedOnTheGhostRow_DisabledWithoutATarget_AndAbsentWi
var subsense = Leaf("mnuDataTree-Subsenses", "CmdDataTree-Insert-SubSense");
var extendedNote = Leaf("mnuDataTree-ExtendedNotes", "CmdDataTree-Insert-ExtNote");
var subsensesRow = Rows().Single(f => f.MenuId == "mnuDataTree-Subsenses");
- var notesRow = Rows().Single(f => f.MenuId == "mnuDataTree-ExtendedNotes");
+ var senseRow = Rows().First(f => f.ObjectHvo == m_entry.SensesOS[0].Hvo && f.Kind == DetailFieldKind.Header);
var etymologyRow = Rows().First(f => f.ObjectHvo == m_etymology.Hvo);
var noObject = new DetailField("Row", "Row", "Senses", null, DetailFieldKind.Header,
EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, menuId: subsensesRow.MenuId);
@@ -425,7 +425,7 @@ public void InsertLeaf_IsOfferedOnTheGhostRow_DisabledWithoutATarget_AndAbsentWi
var onGhost = m_view.InsertItem(subsense, subsensesRow);
Assert.That(onGhost.IsEnabled, Is.True, "the ghost row inserts into the sense it stands in for");
Assert.That(onGhost.Label, Is.EqualTo("Insert Subsense"));
- Assert.That(m_view.InsertItem(extendedNote, notesRow).IsEnabled, Is.True, "the sense has ExtendedNote");
+ Assert.That(m_view.InsertItem(extendedNote, senseRow).IsEnabled, Is.True, "the sense has ExtendedNote");
var noTarget = m_view.InsertItem(extendedNote, etymologyRow);
Assert.That(noTarget.IsEnabled, Is.False, "neither the entry nor an etymology has ExtendedNote");
Assert.That(noTarget.Execute, Is.Null);
diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs
index c98f27b06c..63291fb58c 100644
--- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs
+++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Reflection;
using System.Xml;
using NUnit.Framework;
using SIL.FieldWorks.Common.Controls;
@@ -358,16 +359,16 @@ public void InsertExtendedNote_FromTheFirstNotesHotlinks_GoesRightAfterIt()
Assert.That(m_sense1.ExtendedNoteOS[2], Is.SameAs(m_note2), "the new note sits between the two");
}
- // The Extended Notes section shows only with data, so no prompt row inserts the first.
+ // No section row sits over a sense's notes; the sense's own menu inserts one last.
[Test]
- public void InsertExtendedNote_FromTheSectionRow_GoesLast()
+ public void InsertExtendedNote_FromTheSenseMenu_GoesLast()
{
- var section = Rows(m_entry).Single(f => f.MenuId == "mnuDataTree-ExtendedNotes" && f.ObjectHvo == m_sense1.Hvo);
- Assert.That(section.Kind, Is.EqualTo(DetailFieldKind.Header), "precondition: the section header binds the id");
- Assert.That(Rows(m_entry).Any(f => f.MenuId == "mnuDataTree-ExtendedNotes" && f.ObjectHvo == m_sense2.Hvo), Is.False,
- "precondition: the second sense has no note, so no section row");
+ var sense = HeaderRowOf(m_sense1);
+ Assert.That(sense.MenuId, Is.EqualTo("mnuDataTree-Sense"), "precondition: the sense header binds its menu");
+ Assert.That(Rows(m_entry).Any(f => f.MenuId == "mnuDataTree-ExtendedNotes"), Is.False,
+ "precondition: no section row over the notes");
- InvokeLabelMenuItem(section, "Insert Extended Note");
+ InvokeLabelMenuItem(sense, "Insert Extended Note");
Assert.That(m_sense1.ExtendedNoteOS, Has.Count.EqualTo(3));
Assert.That(m_sense1.ExtendedNoteOS[1], Is.SameAs(m_note2), "the new note is last");
@@ -505,7 +506,14 @@ private void Invoke(string[] ids, DetailField row, DetailMenuKind kind, string l
{
var window = m_propertyTable.GetValue("window");
var request = DetailMenuRequest.FromAnchor(null, row, kind);
- var items = XCoreMenuBridge.CreateMenuItems(window, ids, null, null, m_view.CreateMenuAuthority(request));
+ var authority = m_view.CreateMenuAuthority(request);
+ // As in the host, a menu no authority fully owns targets the adapter at the row.
+ if (!XCoreMenuBridge.OwnsAll(authority, ids))
+ {
+ typeof(RecordEditView).GetMethod("SyncMenuCommandAdapter", BindingFlags.Instance | BindingFlags.NonPublic)
+ .Invoke(m_view, new object[] { row });
+ }
+ var items = XCoreMenuBridge.CreateMenuItems(window, ids, null, null, authority);
var item = items.FirstOrDefault(i => i.Label == label);
Assert.That(item, Is.Not.Null, "the menu offers '{0}'", label);
Assert.That(item.Execute, Is.Not.Null, "'{0}' is enabled", label);