From 466a2da1eab05b6389bafb52acb3dec47cf51e89 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Wed, 21 Oct 2026 11:06:14 -0400 Subject: [PATCH 1/5] LT-22689: Drop the Senses section header over an entry's senses The WinForms DataTree shows no "Senses" row: each sense's own header is the section, at the entry's level. The detail composer added a collapsible "Senses" banner above them and indented every sense one level under it. The composer now emits no banner for any Senses sequence. An entry's sense headers sit at the entry's own depth, and subsenses still indent under their sense. Co-Authored-By: Claude Opus 5.5 --- .../Avalonia/Composer/DetailComposer.cs | 11 ++++--- .../Composer/DetailEditContextEditingTests.cs | 31 +++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 020999c926..b5ffa35576 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -3445,10 +3445,11 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) && 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)) + // Senses get no section banner: each sense's numbered header carries it. An + // entry's sense headers sit at its own level; subsenses indent under their sense. + if (!flatten && !isSenses) AddHeader(node, obj, depth, sectionLabel); + var itemDepth = isSenses && !(obj is ILexSense) ? depth : depth + 1; for (var i = 0; i < count; i++) { @@ -3484,7 +3485,7 @@ 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, @@ -3492,7 +3493,7 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) { ExpansionKey = ExpansionKey(node.StableId + "/item", item) }); - DescendInto(node, item, depth + 1); + DescendInto(node, item, itemDepth); } } diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index 09e914f471..67792dd53b 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,6 +1331,31 @@ 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_Senses_HaveNoBanner_AndSubsensesIndentUnderTheirSense() + { + ILexSense subsense = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + 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"); + } + [Test] public void Compose_SenseHeaders_UseHierarchicalNumbering_WithGlossSummary() { From aef5c1470e60aa1159f80bbbb8c212ae8dc2054b Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Wed, 21 Oct 2026 17:41:24 -0400 Subject: [PATCH 2/5] LT-22689: Word item headers with their summary and collapsed lines Sense, etymology, extended note and notebook subrecord headers now show the text the WinForms DataTree builds from each class's Summary and SummaryCollapsed layouts. A sense reads "Sense 1" while expanded and "Sense 1 - house - n" while collapsed, with the definition in place of the gloss when there is one, and the grammatical info of its MSA. A collapsible header takes an optional collapsed label and swaps between the two as it toggles, keeping its automation name in step. The Etymologies and Extended Notes rows over those items are gone, as is the second SubRecords header under each subrecord; the WinForms view has none of them. Insert Extended Note stays on the sense's menu and on each note's hotlinks. Co-Authored-By: Claude Opus 5.5 --- Src/Common/FwAvalonia/Detail/DataTree.cs | 6 +- Src/Common/FwAvalonia/Detail/DetailModel.cs | 6 + .../FwAvalonia/Detail/DetailSectionHeader.cs | 30 +- .../DetailViewingParityTests.cs | 25 ++ .../Avalonia/Composer/DetailComposer.cs | 48 +-- .../Avalonia/Composer/DetailSummaryLines.cs | 151 ++++++++++ .../Composer/DetailEditContextEditingTests.cs | 29 +- .../Composer/DetailSummaryLinesTests.cs | 278 ++++++++++++++++++ .../Hosting/LexiconMenuAuthorityTests.cs | 14 +- .../Hosting/LexiconMenuExecutionTests.cs | 24 +- 10 files changed, 554 insertions(+), 57 deletions(-) create mode 100644 Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index f18c135229..6b6dd63452 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. diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 7443fef167..10a213f23f 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1702,6 +1702,12 @@ 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; } + /// 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..d396df31ee 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,20 @@ 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; + _text = CreateLabel(CurrentLabel); + _text.VerticalAlignment = VerticalAlignment.Center; + AutomationProperties.SetName(this, CurrentLabel); Content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = FwAvaloniaDensity.TreeBoxGap, - Children = { Box, text } + 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. @@ -68,8 +81,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 +95,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..83372a46da 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs @@ -342,6 +342,31 @@ 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")); + } + [AvaloniaTest] public void HeaderWithNothingUnderIt_DrawsNoBox_AndIgnoresClicks() { diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index b5ffa35576..1aaa458abd 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -275,6 +275,10 @@ 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; + // The sequence item whose header was just emitted with its summary lines, until the + // next row is added; that item's own opening summary row is not emitted again. + private int _itemSummaryHvo; public readonly List Fields = new List(); // One edit handler per composed field, keyed by StableId. A field's text/rich-text/option/ @@ -369,6 +373,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 @@ -394,6 +399,7 @@ private void AddField(DetailField field) StampHelpTopic(field); Fields.Add(field); + _itemSummaryHvo = 0; } // The node whose walk is adding rows right now (see Walk). @@ -1105,7 +1111,10 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) WalkMsaChooser(node, obj, depth); break; case DetailEditorCategory.Summary: - // Summary slices are section header rows in legacy too. + // Summary slices are section header rows in legacy too, except an item's + // opening summary row, which its sequence item header already stands for. + if (obj.Hvo == _itemSummaryHvo) + break; AddHeader(node, obj, depth, Localize(node.Label) ?? node.Field); break; case DetailEditorCategory.Literal: @@ -3433,6 +3442,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, notebook + // records) are sections of their own: each item's header stands for the summary + // row its layout opens with, 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,16 +3455,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); - // Senses get no section banner: each sense's numbered header carries it. An - // entry's sense headers sit at its own level; subsenses indent under their sense. - if (!flatten && !isSenses) + if (!flatten && !summarized) AddHeader(node, obj, depth, sectionLabel); - var itemDepth = isSenses && !(obj is ILexSense) ? depth : depth + 1; + // 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++) { @@ -3462,19 +3477,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 @@ -3491,9 +3497,13 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) hotlinksId: itemBinding.HotlinksId ?? node.HotlinksId, objectHvo: item.Hvo) { - ExpansionKey = ExpansionKey(node.StableId + "/item", item) + ExpansionKey = ExpansionKey(node.StableId + "/item", item), + CollapsedLabel = collapsedLabel }); + if (summarized) + _itemSummaryHvo = item.Hvo; DescendInto(node, item, itemDepth); + _itemSummaryHvo = 0; } } diff --git a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs new file mode 100644 index 0000000000..e0173f6270 --- /dev/null +++ b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs @@ -0,0 +1,151 @@ +// 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.KernelInterfaces; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.Infrastructure; + +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, an extended note and a notebook record. Each + /// pair follows that class's Summary and SummaryCollapsed parts in the shipped + /// configuration (LexSenseParts.xml, LexEntryParts.xml, NotebookParts.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 || obj is IRnGenericRec; + + /// + /// 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; + case IRnGenericRec record: + expanded = IndexInOwner(record) + Lit(" ") + Name(record.TypeRA); + collapsed = expanded + Lit(" - ") + record.Title?.Text; + 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; + } + } + + // A record's IndexInOwnerTSS (its number among its owner's subrecords). + private string IndexInOwner(IRnGenericRec record) + { + var mdc = (IFwMetaDataCacheManaged)_cache.MetaDataCacheAccessor; + if (mdc.FieldExists(RnGenericRecTags.kClassName, "IndexInOwnerTSS", true)) + { + var flid = mdc.GetFieldId(RnGenericRecTags.kClassName, "IndexInOwnerTSS", true); + return _cache.DomainDataByFlid.get_StringProp(record.Hvo, flid)?.Text ?? string.Empty; + } + return (record.IndexInOwner + 1).ToString(); + } + + private string Abbreviation(ICmPossibility possibility) + => possibility == null ? string.Empty : BestAnalysis(possibility.Abbreviation); + + private string Name(ICmPossibility possibility) + => possibility == null + ? string.Empty + : Best(possibility.Name, AnalysisWritingSystems.Concat(VernacularWritingSystems)); + + private IEnumerable AnalysisWritingSystems + => _cache.LangProject.CurrentAnalysisWritingSystems; + + private IEnumerable VernacularWritingSystems + => _cache.LangProject.CurrentVernacularWritingSystems; + + // 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 67792dd53b..1a2d3f3585 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -1356,27 +1356,30 @@ public void Compose_Senses_HaveNoBanner_AndSubsensesIndentUnderTheirSense() "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_UseHierarchicalNumbering_WithGlossSummary() + public void Compose_SenseHeaders_UseHierarchicalNumbers_AndTheGlossWhileCollapsed() { + ILexSense sub = null; NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => { - var sub = Cache.ServiceLocator.GetInstance().Create(); + 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..2258857063 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs @@ -0,0 +1,278 @@ +// 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"))); + } + + private IRnGenericRec RecordWithSubrecord(out IRnGenericRec subrecord) + { + IRnGenericRec record = null, sub = null; + Do(() => + { + if (Cache.LangProject.ResearchNotebookOA == null) + Cache.LangProject.ResearchNotebookOA = Cache.ServiceLocator.GetInstance().Create(); + var notebook = Cache.LangProject.ResearchNotebookOA; + if (notebook.RecTypesOA == null) + notebook.RecTypesOA = Cache.ServiceLocator.GetInstance().Create(); + var type = Cache.ServiceLocator.GetInstance().Create(); + notebook.RecTypesOA.PossibilitiesOS.Add(type); + type.Name.set_String(Cache.DefaultAnalWs, Analysis("Observation")); + record = Cache.ServiceLocator.GetInstance().Create(); + notebook.RecordsOC.Add(record); + sub = Cache.ServiceLocator.GetInstance().Create(); + record.SubRecordsOS.Add(sub); + sub.TypeRA = type; + sub.Title = Analysis("Market day"); + }); + subrecord = sub; + return record; + } + + [Test] + public void Subrecord_ShowsItsNumberAndType_AndWhileCollapsed_ItsTitle() + { + RecordWithSubrecord(out var subrecord); + + var lines = Lines(subrecord); + + Assert.That(lines.Expanded, Does.StartWith("1").And.EndWith(" Observation")); + Assert.That(lines.Collapsed, Is.EqualTo(lines.Expanded + " - Market day")); + } + + [Test] + public void Compose_ASubrecord_HasOneHeaderOfItsOwn_UnderTheSubrecordsSection() + { + var record = RecordWithSubrecord(out var subrecord); + + var headers = DetailComposer.Compose(record, Cache).Model.Fields + .Where(f => f.Kind == DetailFieldKind.Header).ToList(); + + var subrecordHeaders = headers.Where(h => h.ObjectHvo == subrecord.Hvo).ToList(); + Assert.That(subrecordHeaders.Select(h => h.Label), Is.EqualTo(new[] { Lines(subrecord).Expanded }), + "one header for the subrecord, carrying its summary line"); + var section = headers.Single(h => h.Label == "Subrecords"); + Assert.That(subrecordHeaders[0].Indent, Is.EqualTo(section.Indent + 1), + "the subrecord sits directly under the Subrecords section, with no banner between"); + } + + [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))); + } + } +} 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); From b064d34521421d8824a1e3acd48ba017732b8ef1 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 23 Oct 2026 11:29:50 -0400 Subject: [PATCH 3/5] LT-22689: Name the WinForms DataTree in the summary-row comment Co-Authored-By: Claude Opus 5.5 --- Src/xWorks/Avalonia/Composer/DetailComposer.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 1aaa458abd..4872f7b10f 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -1111,8 +1111,9 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) WalkMsaChooser(node, obj, depth); break; case DetailEditorCategory.Summary: - // Summary slices are section header rows in legacy too, except an item's - // opening summary row, which its sequence item header already stands for. + // Summary slices are section header rows, as in the WinForms + // DataTree, except an item's opening summary row: its sequence + // item header stands for it. if (obj.Hvo == _itemSummaryHvo) break; AddHeader(node, obj, depth, Localize(node.Label) ?? node.Field); From aca5f23d0374f8315b33e1376fd652de3258fb4b Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 23 Oct 2026 12:39:30 -0400 Subject: [PATCH 4/5] LT-22689: Leave notebook subrecord headers to LT-22858 Their collapsed header text did not match the WinForms view in testing, so subrecords go back to composing as they do on main. The subrecord summary lines, and the skip of the opening summary row that only subrecords needed, move to LT-22858 with the subrecord fields. Co-Authored-By: Claude Opus 5.5 --- .../Avalonia/Composer/DetailComposer.cs | 19 ++----- .../Avalonia/Composer/DetailSummaryLines.cs | 36 ++----------- .../Composer/DetailSummaryLinesTests.cs | 51 ------------------- 3 files changed, 9 insertions(+), 97 deletions(-) diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 4872f7b10f..906df082f7 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -276,9 +276,6 @@ private sealed class ComposeState private readonly IFwMetaDataCacheManaged _mdc; private readonly HashSet<(int hvo, string layout)> _visited = new HashSet<(int, string)>(); private readonly DetailSummaryLines _summaryLines; - // The sequence item whose header was just emitted with its summary lines, until the - // next row is added; that item's own opening summary row is not emitted again. - private int _itemSummaryHvo; public readonly List Fields = new List(); // One edit handler per composed field, keyed by StableId. A field's text/rich-text/option/ @@ -399,7 +396,6 @@ private void AddField(DetailField field) StampHelpTopic(field); Fields.Add(field); - _itemSummaryHvo = 0; } // The node whose walk is adding rows right now (see Walk). @@ -1111,11 +1107,7 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) WalkMsaChooser(node, obj, depth); break; case DetailEditorCategory.Summary: - // Summary slices are section header rows, as in the WinForms - // DataTree, except an item's opening summary row: its sequence - // item header stands for it. - if (obj.Hvo == _itemSummaryHvo) - break; + // Summary slices are section header rows in legacy too. AddHeader(node, obj, depth, Localize(node.Label) ?? node.Field); break; case DetailEditorCategory.Literal: @@ -3445,9 +3437,9 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) 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, notebook - // records) are sections of their own: each item's header stands for the summary - // row its layout opens with, and no banner sits over them. + // 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 @@ -3501,10 +3493,7 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) ExpansionKey = ExpansionKey(node.StableId + "/item", item), CollapsedLabel = collapsedLabel }); - if (summarized) - _itemSummaryHvo = item.Hvo; DescendInto(node, item, itemDepth); - _itemSummaryHvo = 0; } } diff --git a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs index e0173f6270..62376c3a2a 100644 --- a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs +++ b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs @@ -6,18 +6,16 @@ using System.Linq; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; -using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.WritingSystems; -using SIL.LCModel.Infrastructure; 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, an extended note and a notebook record. Each - /// pair follows that class's Summary and SummaryCollapsed parts in the shipped - /// configuration (LexSenseParts.xml, LexEntryParts.xml, NotebookParts.xml), with their - /// literal text localized through the string table. + /// 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 { @@ -31,7 +29,7 @@ public DetailSummaryLines(LcmCache 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 || obj is IRnGenericRec; + => obj is ILexSense || obj is ILexEtymology || obj is ILexExtendedNote; /// /// The header text of while expanded and while collapsed. Returns @@ -57,10 +55,6 @@ public bool TryGet(ICmObject obj, out string expanded, out string collapsed) collapsed = expanded + Lit(" - ") + Abbreviation(note.ExtendedNoteTypeRA) + Lit(" - ") + BestAnalysis(note.Discussion); return true; - case IRnGenericRec record: - expanded = IndexInOwner(record) + Lit(" ") + Name(record.TypeRA); - collapsed = expanded + Lit(" - ") + record.Title?.Text; - return true; default: expanded = collapsed = null; return false; @@ -102,32 +96,12 @@ private string GrammaticalInfo(IMoMorphSynAnalysis msa) } } - // A record's IndexInOwnerTSS (its number among its owner's subrecords). - private string IndexInOwner(IRnGenericRec record) - { - var mdc = (IFwMetaDataCacheManaged)_cache.MetaDataCacheAccessor; - if (mdc.FieldExists(RnGenericRecTags.kClassName, "IndexInOwnerTSS", true)) - { - var flid = mdc.GetFieldId(RnGenericRecTags.kClassName, "IndexInOwnerTSS", true); - return _cache.DomainDataByFlid.get_StringProp(record.Hvo, flid)?.Text ?? string.Empty; - } - return (record.IndexInOwner + 1).ToString(); - } - private string Abbreviation(ICmPossibility possibility) => possibility == null ? string.Empty : BestAnalysis(possibility.Abbreviation); - private string Name(ICmPossibility possibility) - => possibility == null - ? string.Empty - : Best(possibility.Name, AnalysisWritingSystems.Concat(VernacularWritingSystems)); - private IEnumerable AnalysisWritingSystems => _cache.LangProject.CurrentAnalysisWritingSystems; - private IEnumerable VernacularWritingSystems - => _cache.LangProject.CurrentVernacularWritingSystems; - // The first non-empty alternative in the analysis writing systems, in project order. private string BestAnalysis(IMultiAccessorBase value) => Best(value, AnalysisWritingSystems); diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs index 2258857063..1d0d5c05eb 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs @@ -195,57 +195,6 @@ public void ExtendedNote_WhileCollapsed_ShowsItsTypeAndDiscussion() Assert.That(Lines(note), Is.EqualTo(("Extended Note", "Extended Note - use - formal speech"))); } - private IRnGenericRec RecordWithSubrecord(out IRnGenericRec subrecord) - { - IRnGenericRec record = null, sub = null; - Do(() => - { - if (Cache.LangProject.ResearchNotebookOA == null) - Cache.LangProject.ResearchNotebookOA = Cache.ServiceLocator.GetInstance().Create(); - var notebook = Cache.LangProject.ResearchNotebookOA; - if (notebook.RecTypesOA == null) - notebook.RecTypesOA = Cache.ServiceLocator.GetInstance().Create(); - var type = Cache.ServiceLocator.GetInstance().Create(); - notebook.RecTypesOA.PossibilitiesOS.Add(type); - type.Name.set_String(Cache.DefaultAnalWs, Analysis("Observation")); - record = Cache.ServiceLocator.GetInstance().Create(); - notebook.RecordsOC.Add(record); - sub = Cache.ServiceLocator.GetInstance().Create(); - record.SubRecordsOS.Add(sub); - sub.TypeRA = type; - sub.Title = Analysis("Market day"); - }); - subrecord = sub; - return record; - } - - [Test] - public void Subrecord_ShowsItsNumberAndType_AndWhileCollapsed_ItsTitle() - { - RecordWithSubrecord(out var subrecord); - - var lines = Lines(subrecord); - - Assert.That(lines.Expanded, Does.StartWith("1").And.EndWith(" Observation")); - Assert.That(lines.Collapsed, Is.EqualTo(lines.Expanded + " - Market day")); - } - - [Test] - public void Compose_ASubrecord_HasOneHeaderOfItsOwn_UnderTheSubrecordsSection() - { - var record = RecordWithSubrecord(out var subrecord); - - var headers = DetailComposer.Compose(record, Cache).Model.Fields - .Where(f => f.Kind == DetailFieldKind.Header).ToList(); - - var subrecordHeaders = headers.Where(h => h.ObjectHvo == subrecord.Hvo).ToList(); - Assert.That(subrecordHeaders.Select(h => h.Label), Is.EqualTo(new[] { Lines(subrecord).Expanded }), - "one header for the subrecord, carrying its summary line"); - var section = headers.Single(h => h.Label == "Subrecords"); - Assert.That(subrecordHeaders[0].Indent, Is.EqualTo(section.Indent + 1), - "the subrecord sits directly under the Subrecords section, with no banner between"); - } - [Test] public void Compose_EtymologiesAndExtendedNotes_HaveNoBanner_AndCarryTheirSummaryLines() { From 2500d9c82da4be9fb6163130ef923d598359c6e9 Mon Sep 17 00:00:00 2001 From: Zachary Burnham Date: Fri, 23 Oct 2026 18:30:05 -0400 Subject: [PATCH 5/5] LT-22689: Wrap long section headers; no heavy rule on etymologies A collapsed sense header with a sentence-long definition ran past the right edge of the pane and was lost, since the header text could not wrap and the detail view does not scroll sideways. Header text now wraps to the pane width, as the WinForms summary does, with the +/- box beside its first line. Etymologies and extended notes open with a NormalSummary row, not a HeavySummary one, so their headers no longer draw the heavy top-level section rule that senses keep. Co-Authored-By: Claude Opus 5.5 --- Src/Common/FwAvalonia/Detail/DataTree.cs | 7 ++- Src/Common/FwAvalonia/Detail/DetailModel.cs | 6 +++ .../FwAvalonia/Detail/DetailSectionHeader.cs | 50 ++++++++++++++++--- .../DetailViewingParityTests.cs | 38 ++++++++++++++ .../Avalonia/Composer/DetailComposer.cs | 3 +- .../Avalonia/Composer/DetailSummaryLines.cs | 6 +++ .../Composer/DetailSummaryLinesTests.cs | 4 ++ 7 files changed, 101 insertions(+), 13 deletions(-) diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 6b6dd63452..ac6fd04098 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -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 10a213f23f..5a98a5ae81 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1708,6 +1708,12 @@ public string ExpansionKey /// 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 d396df31ee..464d3f2346 100644 --- a/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs +++ b/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs @@ -49,14 +49,8 @@ public DetailSectionHeader(string label, string collapsedLabel, bool isExpanded, HorizontalAlignment = HorizontalAlignment.Left; HorizontalContentAlignment = HorizontalAlignment.Left; _text = CreateLabel(CurrentLabel); - _text.VerticalAlignment = VerticalAlignment.Center; AutomationProperties.SetName(this, CurrentLabel); - Content = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = FwAvaloniaDensity.TreeBoxGap, - Children = { Box, _text } - }; + Content = new BoxAndLabelPanel { Children = { Box, _text } }; } /// The text shown for the current state. @@ -71,9 +65,49 @@ public DetailSectionHeader(string label, string collapsedLabel, bool isExpanded, 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); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index 83372a46da..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; @@ -367,6 +368,43 @@ public void CollapsibleHeader_ShowsItsCollapsedLine_OnlyWhileCollapsed() 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 906df082f7..f4b1736146 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -3491,7 +3491,8 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) objectHvo: item.Hvo) { ExpansionKey = ExpansionKey(node.StableId + "/item", item), - CollapsedLabel = collapsedLabel + CollapsedLabel = collapsedLabel, + IsNormalWeight = summarized && DetailSummaryLines.IsNormalWeight(item) }); DescendInto(node, item, itemDepth); } diff --git a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs index 62376c3a2a..bee5d9f1d1 100644 --- a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs +++ b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs @@ -31,6 +31,12 @@ public DetailSummaryLines(LcmCache cache) 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. diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs index 1d0d5c05eb..689018f41f 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs @@ -222,6 +222,10 @@ public void Compose_EtymologiesAndExtendedNotes_HaveNoBanner_AndCarryTheirSummar 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"); } } }