From 85e60f0b6a0b566574f5b1cb3540851c72dc58e6 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:56:34 -0400 Subject: [PATCH] Share the detail-view help-topic rule between WinForms and Avalonia The Avalonia detail view will offer the Help command natively once the shared object menu leaves the hidden DataTree adapter. Its topic id must match the one the WinForms slice generates, so the generator moves out of Slice into a rule both views call. FdoUi gains a DetailRules folder, namespace SIL.FieldWorks.Common.DetailRules, for rules both detail views share. Nothing there may depend on WinForms, Avalonia, or a slice or control type, so the rules keep working once WinForms is removed. Two tests enforce that boundary, one over every signature in the namespace and one over the folder's using directives. Co-Authored-By: Claude Fable 5.1 --- Src/Common/Controls/DetailControls/Slice.cs | 160 ++------------ Src/Common/FwAvalonia/Detail/DetailModel.cs | 46 ++++ .../FwAvalonia/Detail/LexiconFirstSlice.cs | 2 +- .../ViewDefinitionJsonSerializer.cs | 4 +- .../ViewDefinition/ViewDefinitionModel.cs | 10 +- .../ViewDefinitionOverrideApplier.cs | 6 +- .../ViewDefinition/XmlLayoutImporter.cs | 8 +- Src/FdoUi/DetailRules/FieldHelpTopics.cs | 205 ++++++++++++++++++ .../DetailRules/DetailRulesBoundaryTests.cs | 110 ++++++++++ .../DetailRules/FieldHelpTopicsTests.cs | 150 +++++++++++++ .../Avalonia/Composer/DetailComposer.cs | 52 ++++- .../Hosting/RecordEditView.Avalonia.cs | 29 +++ .../DetailObjectCommandExecutionTests.cs | 84 +++++++ 13 files changed, 718 insertions(+), 148 deletions(-) create mode 100644 Src/FdoUi/DetailRules/FieldHelpTopics.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs index 6fd9135add..8d850d9a1a 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -8,12 +8,12 @@ using System.Diagnostics; using System.Drawing; using System.Linq; -using System.Text; using System.Windows.Forms; using System.Xml; using SIL.FieldWorks.Common.Controls; using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.RootSites; using SIL.FieldWorks.FdoUi; using SIL.FieldWorks.LexText.Controls; @@ -1398,145 +1398,31 @@ public String GetChooserHelpTopicID(string ChooserDlgHelpTopicID) return GetHelpTopicID(ChooserDlgHelpTopicID, "khtpChoose"); } - private String GetHelpTopicID(String xmlHelpTopicID, String generatedIDPrefix) - { - String helpTopicID; - - if (xmlHelpTopicID == "khtpField-PhRegularRule-RuleFormula") - xmlHelpTopicID = "khtpChoose-Environment"; - - if (!String.IsNullOrEmpty(xmlHelpTopicID)) - helpTopicID = xmlHelpTopicID; - else - { - helpTopicID = GenerateHelpTopicId(generatedIDPrefix); - } - return helpTopicID; - } + // An authored id returns at once; the subject is described only when generating. + private string GetHelpTopicID(string xmlHelpTopicID, string generatedIDPrefix) + => FieldHelpTopics.Resolve(xmlHelpTopicID, generatedIDPrefix, DescribeHelpTopicSubject, helpTopicIsValid); - private string GenerateHelpTopicId(string helpTopicPrefix) + private HelpTopicSubject DescribeHelpTopicSubject() { - String generatedHelpTopicID; - - String tempfieldName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "field"); - String templabelName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "label"); - String areaName = m_propertyTable.GetStringProperty("areaChoice", null); - string toolName = m_propertyTable.GetStringProperty("currentContentControl", null); - int parentHvo = Convert.ToInt32(XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "hvoDisplayParent")); - - if (tempfieldName == "Targets" && parentHvo != 0) - // Ceoss Reference (entry level) or lexical relation (sense level) subitems + var fieldName = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "field"); + var subject = new HelpTopicSubject { - var repo = m_cache.ServiceLocator.GetInstance(); - ILexEntry lex; - repo.TryGetObject(parentHvo, out lex); - - if (lex != null) // It must be the entry level - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-CrossReferenceSubitem"; - } - else // It must be the sense level - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-LexicalRelationSubitem"; - - } - } - else + FieldName = fieldName, + Label = XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "label"), + ClassName = Cache.DomainDataByFlid.MetaDataCache.GetClassName(Object.ClassID), + OwnerClassName = Object.Owner?.ClassName, + SortKey = Object is ICmPossibility ? Object.SortKey : null, + AreaName = m_propertyTable.GetStringProperty("areaChoice", null), + ToolName = m_propertyTable.GetStringProperty("currentContentControl", null) + }; + // A lexical-relation Targets slice carries the entry or sense it is shown under. + var parentHvo = Convert.ToInt32(XmlUtils.GetOptionalAttributeValue(ConfigurationNode, "hvoDisplayParent")); + if (fieldName == "Targets" && parentHvo != 0) { - templabelName = getAlphaNumeric(templabelName); - if (String.IsNullOrEmpty(tempfieldName)) - { - // try to use the slice label, without spaces. - tempfieldName = templabelName; - } - generatedHelpTopicID = GetGeneratedHelpTopicId(helpTopicPrefix, tempfieldName); - if (!helpTopicIsValid(generatedHelpTopicID)) - { - // try to use the slice label, without spaces if the helpTopicID does not work for the field xml attribute. - generatedHelpTopicID = GetGeneratedHelpTopicId(helpTopicPrefix, templabelName); - if (!helpTopicIsValid(generatedHelpTopicID)) - { - if (helpTopicPrefix.Equals("khtpChoose")) - generatedHelpTopicID = "khtpChoose-CmPossibility"; - else if (areaName == "lists") - { - generatedHelpTopicID = "khtp-CustomListField"; // If the list isn't defined, use the generic list help topic - - } - else - { - generatedHelpTopicID = "khtpNoHelpTopic"; // else use the generic no help topic - } - } - } - } - - return generatedHelpTopicID; - } - - private string GetGeneratedHelpTopicId(string helpTopicPrefix, String fieldName) - { - var ownerClassName = Object.Owner == null ? null : Object.Owner.ClassName; - var className = Cache.DomainDataByFlid.MetaDataCache.GetClassName(Object.ClassID); - // Distinguish the Example (sense) field and the expanded example (LexExtendedNote) field - className = (fieldName == "Example" && ownerClassName == "LexExtendedNote") ? "LexExtendedNote" : className; - // Distinguish the Translation (sense) field and the expanded example (LexExtendedNote) field - className = fieldName.StartsWith("Translation")&& (ownerClassName == "LexExtendedNote" || (Object.Owner != null && Object.Owner.ClassName == "LexExtendedNote")) ? "LexExtendedNote" : className; - var toolName = m_propertyTable.GetStringProperty("currentContentControl", null); - - String generatedHelpTopicID; - - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + className + "-" + fieldName; - - if (!helpTopicIsValid(generatedHelpTopicID)) - { - if (String.Equals(className, "CmPossibility")) - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + Object.SortKey + "-" + fieldName; - - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + toolName + "-" + fieldName; - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + className + "-" + fieldName; - if (!helpTopicIsValid(generatedHelpTopicID)) - { - generatedHelpTopicID = helpTopicPrefix + "-" + fieldName; - } - } - } - } - return generatedHelpTopicID; - } - - /// - /// Generates a possible help topic id from the field name, but does NOT check it for validity! - /// - private static string getAlphaNumeric(string fromStr) - { - var candidateID = new StringBuilder(""); - - if (String.IsNullOrEmpty(fromStr)) - return candidateID.ToString(); - - // Should we capitalize the next letter? - bool nextCapital = true; - - // Lets turn our field into a candidate help page! - foreach (char ch in fromStr) - { - if (Char.IsLetterOrDigit(ch)) // might we include numbers someday? - { - if (nextCapital) - candidateID.Append(Char.ToUpper(ch)); - else - candidateID.Append(ch); - nextCapital = false; - } - else // unrecognized character... exclude it - nextCapital = true; // next letter should be a capital + subject.TargetsParentIsEntry = m_cache.ServiceLocator.GetInstance() + .TryGetObject(parentHvo, out _); } - return candidateID.ToString(); + return subject; } /// @@ -1546,9 +1432,7 @@ private bool helpTopicIsValid(String helpStr) { if (m_mediator == null) return false; - var helpTopicProvider = m_propertyTable.GetValue("HelpTopicProvider"); - return (helpTopicProvider != null && !String.IsNullOrEmpty(helpStr)) - && (helpTopicProvider.GetHelpString(helpStr) != null); + return FieldHelpTopics.KnownBy(m_propertyTable.GetValue("HelpTopicProvider"))(helpStr); } /// diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 891bccbb76..eaa6b84b28 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1503,6 +1503,40 @@ public DetailLinkRequest(DetailField field, DetailChooserLink link) public DetailChooserLink Link { get; } } + /// + /// The inputs for generating a row's help topic when the row has no + /// . Plain strings only, so this layer stays free of + /// the domain model. + /// + public sealed class DetailHelpTopicSource + { + /// Captures the generator's inputs for one row. + /// The layout field name; may be null. + /// The raw, unlocalized layout label; may be null. + /// The class of the row's object. + /// The owner's class; null when unowned. + /// The object's sort key. + /// For a lexical-relation row: true under an entry, + /// false under a sense; null for other rows. + public DetailHelpTopicSource(string fieldName, string label, string className, + string ownerClassName, string sortKey, bool? targetsParentIsEntry = null) + { + FieldName = fieldName; + Label = label; + ClassName = className; + OwnerClassName = ownerClassName; + SortKey = sortKey; + TargetsParentIsEntry = targetsParentIsEntry; + } + + public string FieldName { get; } + public string Label { get; } + public string ClassName { get; } + public string OwnerClassName { get; } + public string SortKey { get; } + public bool? TargetsParentIsEntry { get; } + } + /// /// A field on a lexical-edit detail view, projected from a typed and bound to live /// values by an . This is the product contract that replaces the @@ -1601,6 +1635,18 @@ public DetailField( /// public bool CanReorderItems { get; } + /// + /// The layout's authored help topic id, or null when the host generates one from + /// . Settable so the composer can stamp it from the node. + /// + public string HelpTopicId { get; set; } + + /// + /// What the host needs to generate this row's help topic when the layout authors + /// none; null on rows that have no object. Stamped by the composer. + /// + public DetailHelpTopicSource HelpTopicSource { get; set; } + /// False for display-only fields (e.g. reference fields without chooser write-back yet). public bool IsEditable { get; } diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index b582547709..01d386c5e2 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,7 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product); + source.LocalizationKey, automationId, HostRouting.Product, helpTopicId: source.HelpTopicId); private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs index 6c9956aa37..bffd46bc19 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -89,6 +89,7 @@ private static JObject WriteNode(ViewNode node) AddIfPresent(o, "menu", node.MenuId); AddIfPresent(o, "contextMenu", node.ContextMenuId); AddIfPresent(o, "hotlinks", node.HotlinksId); + AddIfPresent(o, "helpTopicID", node.HelpTopicId); AddIfPresent(o, "ghost", node.GhostField); AddIfPresent(o, "ghostWs", node.GhostWs); AddIfPresent(o, "ghostClass", node.GhostClass); @@ -212,7 +213,8 @@ private static ViewNode ReadNode(JToken token) (bool?)o["forVariant"] ?? false, ghostInitMethod: (string)o["ghostInitMethod"], condition: ReadCondition((JObject)o["condition"]), - chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList()); + chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList(), + helpTopicId: (string)o["helpTopicID"]); } private static T ParseEnum(JObject o, string name, T fallback) where T : struct diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 22730bc522..4e8834e557 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -386,8 +386,10 @@ public ViewNode( ViewStringList enumStringList = null, IReadOnlyList visibleWritingSystems = null, bool toggleValue = false, - bool reorder = false) + bool reorder = false, + string helpTopicId = null) { + HelpTopicId = helpTopicId; Reorder = reorder; ToggleValue = toggleValue; VisibleWritingSystems = visibleWritingSystems; @@ -565,6 +567,12 @@ public ViewNode( /// even when the property is virtual; the order persists as a virtual ordering. /// public bool Reorder { get; } + + /// + /// The slice's authored helpTopicID; null when the layout has none and the + /// topic is generated from the field, its object and the tool. + /// + public string HelpTopicId { get; } } /// diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs index 5fcbad085d..fb6e5e9484 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs @@ -309,7 +309,8 @@ private static ViewNode CloneWith(ViewNode n, ViewVisibility visibility, string n.LocalizationKey, n.AutomationId, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, visibleWritingSystems, n.ToggleValue, n.Reorder); + n.ChooserLinks, n.EnumStringList, visibleWritingSystems, n.ToggleValue, n.Reorder, + n.HelpTopicId); // Copy a (leaf) node under a new StableId; AutomationId is dropped so the duplicate gets a fresh, // non-colliding identity (the renderer derives one from the new StableId by convention). @@ -320,6 +321,7 @@ private static ViewNode CloneWithId(ViewNode n, string newId) n.LocalizationKey, null, n.Routing, n.BoldEmphasis, n.FontScalePercent, n.MenuId, n.ContextMenuId, n.HotlinksId, n.GhostField, n.GhostWs, n.GhostClass, n.GhostLabel, n.ForVariant, n.CustomEditorClass, n.CustomEditorAssembly, n.GhostInitMethod, n.Condition, - n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder); + n.ChooserLinks, n.EnumStringList, n.VisibleWritingSystems, n.ToggleValue, n.Reorder, + n.HelpTopicId); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index 9054b94462..c43b5b5298 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -36,7 +36,7 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter { "label", "abbr", "field", "ws", "editor", "visibility", "expansion", "localizationKey", "labelId", "automationId", "routing", "menu", "contextMenu", "hotlinks", - "forVariant", "visibleWritingSystems", "reorder" + "forVariant", "visibleWritingSystems", "reorder", "helpTopicID" }; public static readonly HashSet HandledObjSeqAttributes = @@ -394,7 +394,8 @@ private ViewNode CreateNode( localizationKey, automationId, routing, boldEmphasis, fontScalePercent, menuId, contextMenuId, hotlinksId, chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null, - visibleWritingSystems: visibleWss); + visibleWritingSystems: visibleWss, + helpTopicId: Attr(contentEl, "helpTopicID")); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -416,7 +417,8 @@ private ViewNode CreateNode( toggleValue: ParseOptionalBool(Attr(contentEl, "toggleValue")) ?? false, // The slice's reorder= attribute: its items may be reordered // even when the property is virtual. - reorder: ParseOptionalBool(Attr(contentEl, "reorder")) ?? false); + reorder: ParseOptionalBool(Attr(contentEl, "reorder")) ?? false, + helpTopicId: Attr(contentEl, "helpTopicID")); } case "obj": case "seq": diff --git a/Src/FdoUi/DetailRules/FieldHelpTopics.cs b/Src/FdoUi/DetailRules/FieldHelpTopics.cs new file mode 100644 index 0000000000..b1b266d7ce --- /dev/null +++ b/Src/FdoUi/DetailRules/FieldHelpTopics.cs @@ -0,0 +1,205 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Text; +using SIL.FieldWorks.Common.FwUtils; + +// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice +// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this. +namespace SIL.FieldWorks.Common.DetailRules +{ + /// + /// The field a help topic is resolved for: what the layout and the field's object say + /// about it, plus the area and tool showing it. Every value is a plain string so either + /// detail view can supply it. + /// + public sealed class HelpTopicSubject + { + /// The layout's field name (the field attribute); may be null. + public string FieldName { get; set; } + + /// The layout's raw, unlocalized label; may be null. + public string Label { get; set; } + + /// The class name of the field's object. + public string ClassName { get; set; } + + /// The class name of the object's owner; null for an unowned object. + public string OwnerClassName { get; set; } + + /// The object's sort key; consulted only for a CmPossibility. + public string SortKey { get; set; } + + /// + /// For a lexical-relation Targets field: true when the relation is shown under an + /// entry, false under a sense. Null for every other field. + /// + public bool? TargetsParentIsEntry { get; set; } + + /// The current area choice, e.g. "lexicon" or "lists". + public string AreaName { get; set; } + + /// The current tool, e.g. "lexiconEdit". + public string ToolName { get; set; } + } + + /// + /// Resolves the help topic id of a detail field: the layout's explicit id when it has one, + /// otherwise an id generated from the field, its object and the tool, taking the first + /// candidate the help provider knows. + /// + public static class FieldHelpTopics + { + /// The prefix of a field's own help topic. + public const string FieldPrefix = "khtpField"; + + /// The prefix of a field's chooser-dialog help topic. + public const string ChooserPrefix = "khtpChoose"; + + /// The topic offered when no generated candidate exists. + public const string NoHelpTopic = "khtpNoHelpTopic"; + + /// + /// The help topic for a field whose subject is already known. + /// + /// The layout's helpTopicID; null or empty to + /// generate one. + /// or . + /// The field being resolved. + /// Whether the help provider knows a topic id. + public static string Resolve(string explicitTopicId, string prefix, HelpTopicSubject subject, + Func topicExists) + { + if (subject == null) + throw new ArgumentNullException(nameof(subject)); + return Resolve(explicitTopicId, prefix, () => subject, topicExists); + } + + /// + /// The help topic for a field, describing the subject only when one has to be + /// generated: an explicit id returns before is called. + /// + /// The layout's helpTopicID; null or empty to + /// generate one. + /// or . + /// Describes the field; called at most once. + /// Whether the help provider knows a topic id. + public static string Resolve(string explicitTopicId, string prefix, Func subject, + Func topicExists) + { + if (subject == null) + throw new ArgumentNullException(nameof(subject)); + if (topicExists == null) + throw new ArgumentNullException(nameof(topicExists)); + + var authored = NormalizeExplicit(explicitTopicId); + if (authored != null) + return authored; + var described = subject() ?? throw new InvalidOperationException("The help-topic subject is null."); + return Generate(prefix, described, topicExists); + } + + /// + /// The layout's authored topic as the product uses it, or null when there is none. + /// The rule-formula field's authored topic points at the environment chooser topic. + /// + public static string NormalizeExplicit(string explicitTopicId) + { + if (explicitTopicId == "khtpField-PhRegularRule-RuleFormula") + return "khtpChoose-Environment"; + return string.IsNullOrEmpty(explicitTopicId) ? null : explicitTopicId; + } + + /// + /// The topic-exists predicate for a help provider: a non-empty id the provider has a + /// string for. A null provider knows nothing. + /// + public static Func KnownBy(IHelpTopicProvider provider) + => id => provider != null && !string.IsNullOrEmpty(id) && provider.GetHelpString(id) != null; + + private static string Generate(string prefix, HelpTopicSubject subject, Func topicExists) + { + // Cross-reference (entry level) and lexical-relation (sense level) subitems share one + // topic per level. + if (subject.FieldName == "Targets" && subject.TargetsParentIsEntry.HasValue) + { + return subject.TargetsParentIsEntry.Value + ? prefix + "-" + subject.ToolName + "-CrossReferenceSubitem" + : prefix + "-" + subject.ToolName + "-LexicalRelationSubitem"; + } + + var labelId = ToAlphanumericId(subject.Label); + var fieldName = string.IsNullOrEmpty(subject.FieldName) ? labelId : subject.FieldName; + var candidate = GenerateForField(prefix, fieldName, subject, topicExists); + if (topicExists(candidate)) + return candidate; + // The field attribute gave nothing the provider knows; try the label. + candidate = GenerateForField(prefix, labelId, subject, topicExists); + if (topicExists(candidate)) + return candidate; + if (prefix == ChooserPrefix) + return "khtpChoose-CmPossibility"; + // An undefined list falls back to the generic list-field topic. + return subject.AreaName == "lists" ? "khtp-CustomListField" : NoHelpTopic; + } + + // Candidates from most to least specific: tool+class+field, the possibility's sort key, + // tool+field, class+field, field. The last is returned unverified. + private static string GenerateForField(string prefix, string fieldName, HelpTopicSubject subject, + Func topicExists) + { + var className = subject.ClassName; + // An Example or Translation under an extended note has its own topics. + if ((fieldName == "Example" || (fieldName ?? string.Empty).StartsWith("Translation", StringComparison.Ordinal)) + && subject.OwnerClassName == "LexExtendedNote") + { + className = "LexExtendedNote"; + } + var tool = subject.ToolName; + + var candidate = prefix + "-" + tool + "-" + className + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + if (className == "CmPossibility") + { + candidate = prefix + "-" + tool + "-" + subject.SortKey + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + } + candidate = prefix + "-" + tool + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + candidate = prefix + "-" + className + "-" + fieldName; + if (topicExists(candidate)) + return candidate; + return prefix + "-" + fieldName; + } + + /// + /// A label reduced to a topic-id fragment: letters and digits only, each run + /// capitalized ("Complex Forms" becomes "ComplexForms"). Empty for a null label. + /// + public static string ToAlphanumericId(string label) + { + var id = new StringBuilder(); + if (string.IsNullOrEmpty(label)) + return string.Empty; + var nextCapital = true; + foreach (var ch in label) + { + if (char.IsLetterOrDigit(ch)) + { + id.Append(nextCapital ? char.ToUpper(ch) : ch); + nextCapital = false; + } + else + { + nextCapital = true; + } + } + return id.ToString(); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs new file mode 100644 index 0000000000..078a4882d6 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailRulesBoundaryTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The DetailRules boundary: rules shared by the WinForms and Avalonia detail views take + /// domain objects and plain values only, so they keep working once WinForms is removed. + /// One test checks every signature in the namespace, the other the folder's using + /// directives, which is where a method body would pull a UI stack in. + /// + [TestFixture] + public class DetailRulesBoundaryTests + { + private static readonly string[] ForbiddenAssemblyPrefixes = + { + "System.Windows.Forms", "System.Drawing", "Avalonia", "DetailControls", "FwControls", + "RootSite", "SimpleRootSite", "XMLViews", "Framework", "FwAvalonia" + }; + + private static readonly Regex ForbiddenUsing = new Regex( + @"^\s*using\s+(static\s+)?(System\.Windows\.Forms|System\.Drawing|Avalonia|SIL\.FieldWorks\.Common\.Framework\.DetailControls|SIL\.FieldWorks\.Common\.Controls|SIL\.FieldWorks\.Common\.FwAvalonia|SIL\.FieldWorks\.Common\.RootSites)", + RegexOptions.Compiled); + + [Test] + public void DetailRulesTypes_ReferenceNoUiStackInTheirSignatures() + { + var rulesNamespace = typeof(FieldHelpTopics).Namespace; + var types = typeof(FieldHelpTopics).Assembly.GetTypes() + .Where(t => t.Namespace == rulesNamespace).ToList(); + Assert.That(types, Is.Not.Empty); + + var offences = new List(); + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly; + foreach (var type in types) + { + Check(type.BaseType, type.Name + " : base", offences); + foreach (var i in type.GetInterfaces()) + Check(i, type.Name + " : interface", offences); + foreach (var f in type.GetFields(all)) + Check(f.FieldType, type.Name + "." + f.Name, offences); + foreach (var p in type.GetProperties(all)) + Check(p.PropertyType, type.Name + "." + p.Name, offences); + foreach (var m in type.GetMethods(all).Cast().Concat(type.GetConstructors(all))) + { + if (m is MethodInfo mi) + Check(mi.ReturnType, type.Name + "." + m.Name + " returns", offences); + foreach (var prm in m.GetParameters()) + Check(prm.ParameterType, type.Name + "." + m.Name + "(" + prm.Name + ")", offences); + } + } + Assert.That(offences, Is.Empty, "DetailRules must not depend on a UI stack"); + } + + [Test] + public void DetailRulesSources_ImportNoUiStackNamespace() + { + var folder = FindDetailRulesFolder(); + Assume.That(folder, Is.Not.Null, "the repository sources are not beside the test output"); + var offences = new List(); + foreach (var file in Directory.GetFiles(folder, "*.cs", SearchOption.AllDirectories)) + { + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + if (ForbiddenUsing.IsMatch(lines[i])) + offences.Add(Path.GetFileName(file) + ":" + (i + 1) + " " + lines[i].Trim()); + } + } + Assert.That(offences, Is.Empty, "DetailRules sources must not import a UI stack"); + } + + // The repo's Src/FdoUi/DetailRules, found by walking up from the test output folder. + private static string FindDetailRulesFolder() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "Src", "FdoUi", "DetailRules"); + if (Directory.Exists(candidate)) + return candidate; + } + return null; + } + + private static void Check(Type type, string site, List offences) + { + if (type == null) + return; + if (type.IsGenericType) + { + foreach (var arg in type.GetGenericArguments()) + Check(arg, site, offences); + } + var assembly = type.Assembly.GetName().Name; + if (ForbiddenAssemblyPrefixes.Any(p => assembly.StartsWith(p, StringComparison.Ordinal))) + offences.Add(site + " -> " + type.FullName); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs new file mode 100644 index 0000000000..deb231fa33 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/FieldHelpTopicsTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The help-topic rule against a provider that knows a chosen set of topics, so each + /// fallback level is reached deliberately. + /// + [TestFixture] + public class FieldHelpTopicsTests + { + private static Func Knows(params string[] topics) + { + var known = new HashSet(topics, StringComparer.Ordinal); + return known.Contains; + } + + private static HelpTopicSubject Gloss() + => new HelpTopicSubject + { + FieldName = "Gloss", Label = "Gloss", ClassName = "LexSense", + OwnerClassName = "LexEntry", AreaName = "lexicon", ToolName = "lexiconEdit" + }; + + [Test] + public void ExplicitTopic_Wins_EvenWhenUnknown() + { + Assert.That(FieldHelpTopics.Resolve("khtpField-Authored", FieldHelpTopics.FieldPrefix, Gloss(), Knows()), + Is.EqualTo("khtpField-Authored")); + } + + [Test] + public void TheRuleFormulaTopic_RedirectsToTheEnvironmentChooser() + { + Assert.That(FieldHelpTopics.Resolve("khtpField-PhRegularRule-RuleFormula", FieldHelpTopics.FieldPrefix, + Gloss(), Knows()), Is.EqualTo("khtpChoose-Environment")); + } + + [Test] + public void Generated_PrefersToolClassField() + { + var known = Knows("khtpField-lexiconEdit-LexSense-Gloss", "khtpField-lexiconEdit-Gloss", "khtpField-Gloss"); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), known), + Is.EqualTo("khtpField-lexiconEdit-LexSense-Gloss")); + } + + [Test] + public void Generated_FallsThroughToolField_ClassField_ThenField() + { + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-lexiconEdit-Gloss")), + Is.EqualTo("khtpField-lexiconEdit-Gloss")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-LexSense-Gloss")), + Is.EqualTo("khtpField-LexSense-Gloss")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows("khtpField-Gloss")), + Is.EqualTo("khtpField-Gloss")); + } + + [Test] + public void Generated_TriesTheLabel_WhenTheFieldNameYieldsNothing() + { + var subject = Gloss(); + subject.Label = "Complex Forms"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, + Knows("khtpField-lexiconEdit-LexSense-ComplexForms")), + Is.EqualTo("khtpField-lexiconEdit-LexSense-ComplexForms")); + } + + [Test] + public void Generated_UsesTheLabel_WhenThereIsNoFieldName() + { + var subject = Gloss(); + subject.FieldName = null; + subject.Label = "Semantic Domains"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, Knows("khtpField-SemanticDomains")), + Is.EqualTo("khtpField-SemanticDomains")); + } + + [Test] + public void Generated_NothingKnown_GivesTheNoTopicDefault_OrTheListDefault() + { + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, Gloss(), Knows()), + Is.EqualTo(FieldHelpTopics.NoHelpTopic)); + var list = Gloss(); + list.AreaName = "lists"; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, list, Knows()), + Is.EqualTo("khtp-CustomListField")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.ChooserPrefix, Gloss(), Knows()), + Is.EqualTo("khtpChoose-CmPossibility")); + } + + [Test] + public void Generated_Possibility_TriesItsSortKey() + { + var subject = new HelpTopicSubject + { + FieldName = "Name", ClassName = "CmPossibility", SortKey = "Genres", + AreaName = "lists", ToolName = "genresEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, subject, Knows("khtpField-genresEdit-Genres-Name")), + Is.EqualTo("khtpField-genresEdit-Genres-Name")); + } + + [Test] + public void Generated_ExtendedNoteExampleAndTranslation_UseTheNoteClass() + { + var example = new HelpTopicSubject + { + FieldName = "Example", ClassName = "LexExampleSentence", OwnerClassName = "LexExtendedNote", + ToolName = "lexiconEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, example, + Knows("khtpField-lexiconEdit-LexExtendedNote-Example")), + Is.EqualTo("khtpField-lexiconEdit-LexExtendedNote-Example")); + var translation = new HelpTopicSubject + { + FieldName = "Translations", ClassName = "LexExampleSentence", OwnerClassName = "LexExtendedNote", + ToolName = "lexiconEdit" + }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, translation, + Knows("khtpField-lexiconEdit-LexExtendedNote-Translations")), + Is.EqualTo("khtpField-lexiconEdit-LexExtendedNote-Translations")); + } + + [Test] + public void Targets_UnderAnEntry_IsACrossReference_UnderASense_ALexicalRelation() + { + var entry = new HelpTopicSubject { FieldName = "Targets", ToolName = "lexiconEdit", TargetsParentIsEntry = true }; + var sense = new HelpTopicSubject { FieldName = "Targets", ToolName = "lexiconEdit", TargetsParentIsEntry = false }; + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, entry, Knows()), + Is.EqualTo("khtpField-lexiconEdit-CrossReferenceSubitem")); + Assert.That(FieldHelpTopics.Resolve(null, FieldHelpTopics.FieldPrefix, sense, Knows()), + Is.EqualTo("khtpField-lexiconEdit-LexicalRelationSubitem")); + } + + [Test] + public void ToAlphanumericId_CapitalizesEachRun_AndDropsTheRest() + { + Assert.That(FieldHelpTopics.ToAlphanumericId("Complex Forms"), Is.EqualTo("ComplexForms")); + Assert.That(FieldHelpTopics.ToAlphanumericId("semantic-domain 2"), Is.EqualTo("SemanticDomain2")); + Assert.That(FieldHelpTopics.ToAlphanumericId(null), Is.EqualTo(string.Empty)); + } + } +} diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index d748be529a..e1801a2260 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -385,10 +385,39 @@ private void AddField(DetailField field) field.ClassName = ctx.ClassName; field.LayoutName = ctx.LayoutName; } + StampHelpTopic(field); Fields.Add(field); } + // The node whose walk is adding rows right now (see Walk). + private readonly Stack _walkNodes = new Stack(); + + // A row takes its help-topic inputs from the node being walked unless the site set + // them itself (the lexical-relation rows). + private void StampHelpTopic(DetailField field) + { + if (_walkNodes.Count == 0) + return; + var node = _walkNodes.Peek(); + if (field.HelpTopicId == null) + field.HelpTopicId = node.HelpTopicId; + if (field.HelpTopicSource == null) + field.HelpTopicSource = HelpTopicSourceFor(node.Field, node.Label, field.ObjectHvo); + } + + // The object-derived half of the help-topic inputs; null when the row has no object. + private DetailHelpTopicSource HelpTopicSourceFor(string fieldName, string label, int hvo, + bool? targetsParentIsEntry = null) + { + if (hvo == 0 || !_cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var obj)) + return null; + // Only a possibility's topic can use the sort key, and computing it is not free. + var sortKey = obj is ICmPossibility ? obj.SortKey : null; + return new DetailHelpTopicSource(fieldName, label, _mdc.GetClassName(obj.ClassID), + obj.Owner?.ClassName, sortKey, targetsParentIsEntry); + } + // The project's character-type style names, sourced from // Cache.LangProject.StylesOC (the LcmStyleSheet's backing store) and filtered to // StyleType.kstCharacter -- the same set the legacy character-style combo offers. @@ -577,6 +606,20 @@ public void Walk(ViewNode node, ICmObject obj, int depth) if (IsHidden(node) || depth > MaxDepth || IsIrrelevantForObject(node, obj)) return; + // Rows added while this node walks are stamped with its help-topic inputs. + _walkNodes.Push(node); + try + { + WalkByKind(node, obj, depth); + } + finally + { + _walkNodes.Pop(); + } + } + + private void WalkByKind(ViewNode node, ICmObject obj, int depth) + { switch (node.Kind) { case ViewNodeKind.Field: @@ -1830,13 +1873,18 @@ private void AddLexicalRelationRows(ViewNode node, ICmObject obj, int depth) if (row.IsEditable) searchOptions = query => SearchLexicalRelationTargets(query, obj, relation, row.MappingType); - AddField(new DetailField(stableId, row.Label, node.Field, node.WritingSystem, + var relationRow = new DetailField(stableId, row.Label, node.Field, node.WritingSystem, DetailFieldKind.ReferenceVector, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, isEditable: row.IsEditable, indent: depth, menuId: row.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, objectHvo: relation.Hvo, items: items, - searchOptions: searchOptions)); + searchOptions: searchOptions); + // WinForms shows a relation as a Targets slice under the entry or sense, and + // keys its help topic on that. + relationRow.HelpTopicSource = HelpTopicSourceFor("Targets", row.Label, relation.Hvo, + targetsParentIsEntry: obj is ILexEntry); + AddField(relationRow); if (!row.IsEditable) continue; diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index b98840e967..ad07820a96 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Windows.Forms; using System.Xml; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.Seams; @@ -590,6 +591,34 @@ private ViewDefinitionOverrideStore ViewOverrideStore } } + /// + /// The help topic of a detail row: its when set, + /// else one generated from the row's field and object and the current tool. Null when + /// the row carries nothing to generate from. + /// + internal string ResolveHelpTopic(DetailField field) + { + if (field == null) + return null; + var source = field.HelpTopicSource; + if (string.IsNullOrEmpty(field.HelpTopicId) && source == null) + return null; + var provider = m_propertyTable.GetValue("HelpTopicProvider"); + var subject = new HelpTopicSubject + { + FieldName = source?.FieldName, + Label = source?.Label, + ClassName = source?.ClassName, + OwnerClassName = source?.OwnerClassName, + SortKey = source?.SortKey, + TargetsParentIsEntry = source?.TargetsParentIsEntry, + AreaName = m_propertyTable.GetStringProperty("areaChoice", null), + ToolName = m_propertyTable.GetStringProperty("currentContentControl", null) + }; + return FieldHelpTopics.Resolve(field.HelpTopicId, FieldHelpTopics.FieldPrefix, subject, + FieldHelpTopics.KnownBy(provider)); + } + // The resolver the composer calls for each compiled (class, layout); null result = shipped // definition. A load failure is logged, not fatal -- compose then uses the shipped // definition. diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index ff278aa98d..032823be6c 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -299,6 +299,90 @@ public void SubentriesMoveRight_ThroughTheHost_ReordersTheRow() "the host re-shows the record after the coalesced completion"); } + // Help topics: every composed row that has a WinForms slice twin must resolve the SAME + // topic id the slice generates, with the provider knowing nothing and knowing much. + + private sealed class PatternHelpTopicProvider : IHelpTopicProvider + { + private readonly Func _knows; + public PatternHelpTopicProvider(Func knows) { _knows = knows; } + public string GetHelpString(string id) => id != null && _knows(id) ? id : null; + public string HelpFile => string.Empty; + } + + // A lexical relation from the test entry to another, so the entry composes a relation + // row whose WinForms twin is a Targets slice. + private void MakeLexicalRelation() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var stem = GetMorphTypeOrCreateOne("stem"); + var noun = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + var other = AddLexeme(m_createdObjects, "command-entry-synonym", stem, "synonym", noun); + var lexDb = Cache.LangProject.LexDbOA; + if (lexDb.ReferencesOA == null) + lexDb.ReferencesOA = Cache.ServiceLocator.GetInstance().Create(); + var type = Cache.ServiceLocator.GetInstance().Create(); + lexDb.ReferencesOA.PossibilitiesOS.Add(type); + type.MappingType = (int)LexRefTypeTags.MappingTypes.kmtEntryCollection; + type.Name.set_String(Cache.DefaultAnalWs, "Synonyms"); + var reference = Cache.ServiceLocator.GetInstance().Create(); + type.MembersOC.Add(reference); + reference.TargetsRS.Add(m_entry); + reference.TargetsRS.Add(other); + }); + DrainMediatorAndIdleQueues(); + } + + [TestCase(false)] + [TestCase(true)] + public void HelpTopics_ResolveLikeTheWinFormsSlices_ForEveryTwinnedRow(bool providerKnowsToolTopics) + { + AddSense("second gloss"); + MakeTwoSubentries(); + MakeLexicalRelation(); + // The provider decides which generated candidate wins; both sides read the same one. + m_propertyTable.SetProperty("HelpTopicProvider", + new PatternHelpTopicProvider(id => providerKnowsToolTopics && id.Contains("-lexiconEdit-")), false); + m_propertyTable.SetPropertyPersistence("HelpTopicProvider", false); + EnsureAdapter(m_entry.Hvo); + var dataTree = (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields; + + var compared = 0; + var relationsCompared = 0; + var mismatches = new List(); + foreach (var sliceObj in dataTree.Slices) + { + if (!(sliceObj is Slice slice) || slice.Object == null || slice.IsLazyPlaceholder || slice.Flid == 0) + continue; + var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor; + var sliceField = mdc.FieldExists(slice.Flid) ? mdc.GetFieldName(slice.Flid) : null; + if (sliceField == null) + continue; + // A relation's WinForms twin is a Targets slice on the relation; the row keeps + // the + // relations property as its field and names Targets in its help-topic source. + var twin = sliceField == "Targets" + ? fields.FirstOrDefault(f => f.ObjectHvo == slice.Object.Hvo + && f.HelpTopicSource?.FieldName == "Targets") + : fields.FirstOrDefault(f => f.ObjectHvo == slice.Object.Hvo + && string.Equals(f.Field, sliceField, StringComparison.Ordinal)); + if (twin == null) + continue; + compared++; + if (sliceField == "Targets") + relationsCompared++; + var expected = slice.GetSliceHelpTopicID(); + var actual = m_view.ResolveHelpTopic(twin); + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + mismatches.Add($"{sliceField} on {slice.Object.ClassName}: slice '{expected}', row '{actual}'"); + } + Assert.That(compared, Is.GreaterThan(5), "enough rows have a WinForms twin to make the comparison meaningful"); + Assert.That(relationsCompared, Is.GreaterThan(0), "the lexical relation's Targets slice found its row"); + Assert.That(mismatches, Is.Empty); + } + // ---------------------------------------------------------------------------------------- // Delete Sense / Delete object // ----------------------------------------------------------------------------------------