From 4ed7b00e00f6d161babed92272f4e934ce9c8ad2 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:54:19 -0400 Subject: [PATCH] LT-22691: Answer the per-object and Help menus natively Every detail row's label menu merges mnuDataTree-Object (Field Visibility, Move Field, Help), and 329 rows bind the empty mnuDataTree-Help. Both ran through the mediator, so the hidden DataTree adapter took part in every label menu, and Help was answered from the adapter's current slice. A row with no slice twin, such as Publish Sense In or Reversal Entries, left the adapter pointed at another slice, so Help opened that slice's topic. An ordinary row's label menu is now answered natively in full, so the hidden adapter no longer takes part in it; it is still pointed at the row when the WinForms menu has to stand in. A row whose override target cannot be located shows Field Visibility and Move Field disabled instead of running them through the mediator. One rule changed on the way: ResolveHelpTopic no longer returns null. A row with no object generates from its field and label alone, so every row ends at a topic, the generic one at worst, as a WinForms slice does. Co-Authored-By: Claude Fable 5.1 --- .../Hosting/CompositeMenuAuthority.cs | 45 +++ .../Avalonia/Hosting/ObjectMenuAuthority.cs | 131 ++++++++ .../Hosting/RecordEditView.Avalonia.cs | 109 +++++-- .../DetailObjectCommandExecutionTests.cs | 296 +++++++++++++++++- 4 files changed, 546 insertions(+), 35 deletions(-) create mode 100644 Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs create mode 100644 Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs diff --git a/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs new file mode 100644 index 0000000000..bd5ff2ac69 --- /dev/null +++ b/Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs @@ -0,0 +1,45 @@ +// 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 SIL.FieldWorks.Common.FwAvalonia.Detail; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// One authority over several: a menu id is answered by the first member that owns it. + /// + internal sealed class CompositeMenuAuthority : IDetailMenuAuthority + { + private readonly IDetailMenuAuthority[] _members; + + public CompositeMenuAuthority(params IDetailMenuAuthority[] members) + { + _members = members ?? throw new ArgumentNullException(nameof(members)); + if (Array.IndexOf(_members, null) >= 0) + throw new ArgumentException("A member authority is null.", nameof(members)); + } + + public bool Owns(string menuId) => Owner(menuId) != null; + + public DetailMenuItem Build(string menuId, ChoiceBase leaf) + { + var owner = Owner(menuId); + if (owner == null) + throw new InvalidOperationException(string.Format("No authority owns menu '{0}'.", menuId)); + return owner.Build(menuId, leaf); + } + + private IDetailMenuAuthority Owner(string menuId) + { + foreach (var member in _members) + { + if (member.Owns(menuId)) + return member; + } + return null; + } + } +} diff --git a/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs new file mode 100644 index 0000000000..52806d1a9f --- /dev/null +++ b/Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The native authority for the per-object menu every detail row merges (Field Visibility, + /// Move Field, Help) and for the empty Help menu most rows bind. Field Visibility and Move + /// Field act on the row's node in the project override layer; Help opens the row's own + /// topic. A row whose override target cannot be located keeps those items, disabled. + /// + internal sealed class ObjectMenuAuthority : IDetailMenuAuthority + { + internal const string MenuId = RecordEditView.ObjectMenuId; + internal const string HelpMenuId = "mnuDataTree-Help"; + internal const string AlwaysVisibleCommandId = "CmdAlwaysVisible"; + internal const string IfDataCommandId = "CmdIfData"; + internal const string NormallyHiddenCommandId = "CmdNormallyHidden"; + internal const string MoveFieldUpCommandId = "CmdDataTree-MoveFieldUp"; + internal const string MoveFieldDownCommandId = "CmdDataTree-MoveFieldDown"; + internal const string HelpCommandId = "CmdDataTree-Help"; + + private readonly Lazy _target; + private readonly Func _fieldVisibility; + private readonly Func _moveField; + private readonly Lazy _helpTopic; + private readonly Action _showHelp; + + /// Creates the authority for one row's menu. + /// The row. + /// The row's override target, or null when it cannot be + /// located; asked once, on the first Field Visibility or Move Field leaf. + /// Builds a Field Visibility item from (label, target, + /// visibility). + /// Builds a Move Field item from (label, target, up). + /// The row's help topic when the help provider has it, else + /// null; asked once. + /// Opens a help topic. + public ObjectMenuAuthority(DetailField field, Func locateTarget, + Func fieldVisibility, + Func moveField, + Func helpTopic, Action showHelp) + { + if (field == null) + throw new ArgumentNullException(nameof(field)); + if (locateTarget == null) + throw new ArgumentNullException(nameof(locateTarget)); + if (helpTopic == null) + throw new ArgumentNullException(nameof(helpTopic)); + _fieldVisibility = fieldVisibility ?? throw new ArgumentNullException(nameof(fieldVisibility)); + _moveField = moveField ?? throw new ArgumentNullException(nameof(moveField)); + _showHelp = showHelp ?? throw new ArgumentNullException(nameof(showHelp)); + _target = new Lazy(() => locateTarget(field)); + _helpTopic = new Lazy(() => helpTopic(field)); + } + + public bool Owns(string menuId) + => string.Equals(menuId, MenuId, StringComparison.Ordinal) + || string.Equals(menuId, HelpMenuId, StringComparison.Ordinal); + + public DetailMenuItem Build(string menuId, ChoiceBase leaf) + { + if (leaf == null) + throw new ArgumentNullException(nameof(leaf)); + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + switch (leaf.HelpId) + { + case AlwaysVisibleCommandId: + return VisibilityItem(label, ViewVisibility.Always); + case IfDataCommandId: + return VisibilityItem(label, ViewVisibility.IfData); + case NormallyHiddenCommandId: + return VisibilityItem(label, ViewVisibility.Never); + case MoveFieldUpCommandId: + return MoveItem(label, up: true); + case MoveFieldDownCommandId: + return MoveItem(label, up: false); + case HelpCommandId: + return HelpItem(label); + default: + throw new InvalidOperationException(string.Format( + "Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.HelpId)); + } + } + + // Offered on every row; without a located target it is disabled rather than guessed. + private DetailMenuItem VisibilityItem(string label, ViewVisibility visibility) + => _target.Value == null ? Disabled(label) : _fieldVisibility(label, _target.Value, visibility); + + private DetailMenuItem MoveItem(string label, bool up) + => _target.Value == null ? Disabled(label) : _moveField(label, _target.Value, up); + + // Hidden when the help provider has no topic for the row, as WinForms hides it. + private DetailMenuItem HelpItem(string label) + { + var topic = _helpTopic.Value; + if (topic == null) + return null; + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => _showHelp(topic)); + } + + private static DetailMenuItem Disabled(string label) + => new DetailMenuItem(label, isEnabled: false, isChecked: false, children: null, execute: null); + } + + /// + /// A row's node in its own compiled model with the current override applied: what Field + /// Visibility reads its checkmark from and Move Field its enablement. + /// + internal sealed class OverrideTarget + { + public OverrideTarget(string templateId, ViewNodeLocation location) + { + TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId)); + Location = location ?? throw new ArgumentNullException(nameof(location)); + } + + /// The row's template id, without its runtime suffix. + public string TemplateId { get; } + + /// The node's position and visibility among its siblings. + public ViewNodeLocation Location { get; } + } +} diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs index 390481f3a8..890000d728 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs @@ -515,31 +515,27 @@ private void OnDetailMenuRequested(DetailMenuRequest request) // Only ids without a native authority need the hidden command adapter. An adapter // failure must not suppress the menu: its items disable, the rest still works. - var authority = CreateReorderVectorAuthority(request); - if (!XCoreMenuBridge.OwnsAll(authority, idArray)) - { - try - { - EnsureMenuCommandAdapter(request.Field.ObjectHvo, request.Field.Field); - } - catch (Exception adapterError) - { - Logger.WriteError("Detail menu command adapter failed; menu items that need " - + "the hidden colleague chain will be disabled.", adapterError); - } - } + var authority = CreateMenuAuthority(request); + var ownsAll = XCoreMenuBridge.OwnsAll(authority, idArray); + if (!ownsAll) + SyncMenuCommandAdapter(request.Field); // Render the SAME xCore menu natively in Avalonia -- identical items, // enablement, and mediator dispatch; only rendering changes. The WinForms // adapter menu remains the fallback if materialization fails. try { - // Field Visibility / Move Field retarget to the override layer; other - // mediator-answered commands keep their dispatch. - var registry = new OverrideCommandRegistry(); - AddOverrideCommands(registry, request.Field); - var items = XCoreMenuBridge.CreateMenuItems(window, idArray, registry.TryBuild, null, - authority); + // On the mediator path Field Visibility / Move Field retarget to the override + // layer; other mediator-answered commands keep their dispatch. An owned menu + // never consults the interceptor, so it is not built. + Func interceptor = null; + if (!ownsAll) + { + var registry = new OverrideCommandRegistry(); + AddOverrideCommands(registry, request.Field); + interceptor = registry.TryBuild; + } + var items = XCoreMenuBridge.CreateMenuItems(window, idArray, interceptor, null, authority); if (items.Count > 0) { // A keyboard-opened menu anchors under the row it came from; a @@ -555,6 +551,10 @@ private void OnDetailMenuRequested(DetailMenuRequest request) nativeMenuError); } + // The adapter menu answers from the hidden tree's current slice, which an owned + // menu never pointed at this row. + if (ownsAll) + SyncMenuCommandAdapter(request.Field); window.ShowContextMenu(idArray, AdapterMenuScreenPoint(request), null, null); } catch (Exception e) @@ -597,21 +597,20 @@ 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. + /// else one generated from the row's field and object and the current tool. A row with + /// no object generates from its field and label alone, so every row ends at a topic, + /// the generic one at worst. /// 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, + FieldName = source?.FieldName ?? field.Field, + Label = source?.Label ?? field.Label, ClassName = source?.ClassName, OwnerClassName = source?.OwnerClassName, SortKey = source?.SortKey, @@ -674,6 +673,66 @@ internal void AddOverrideCommands(OverrideCommandRegistry registry, DetailField private static string LabelOf(UIItemDisplayProperties display) => XCoreMenuBridge.StripAccelerator(display.Text); + /// + /// The native authorities for the request's row: the reorder-vector menu and the shared + /// per-object and Help menus, so a label menu made only of those ids needs nothing from + /// the hidden command adapter. + /// + internal IDetailMenuAuthority CreateMenuAuthority(DetailMenuRequest request) + => new CompositeMenuAuthority(CreateReorderVectorAuthority(request), + CreateObjectMenuAuthority(request.Field)); + + /// The native authority for the row's per-object and Help menus. + internal IDetailMenuAuthority CreateObjectMenuAuthority(DetailField field) + => new ObjectMenuAuthority(field, LocateOverrideTarget, + fieldVisibility: (label, target, visibility) => + VisibilityItem(label, field, target.TemplateId, target.Location, visibility), + moveField: (label, target, up) => MoveItem(label, field, target.Location, up), + helpTopic: KnownHelpTopic, + showHelp: ShowDetailHelp); + + // The row's override target, or null (with the reason logged) when it cannot be located. + // A failure disables the row's field commands rather than failing the whole menu. + private OverrideTarget LocateOverrideTarget(DetailField field) + { + try + { + return TryLocateOverrideTarget(field, out var templateId, out var location) + ? new OverrideTarget(templateId, location) + : null; + } + catch (Exception e) + { + Logger.WriteError("Locating the row's override target failed; its Field Visibility and " + + "Move Field commands are disabled.", e); + return null; + } + } + + /// + /// The row's help topic when the help provider has it, else null. A failure hides Help + /// rather than failing the whole menu. + /// + internal string KnownHelpTopic(DetailField field) + { + try + { + var topic = ResolveHelpTopic(field); + if (topic == null) + return null; + var provider = m_propertyTable.GetValue("HelpTopicProvider"); + return provider?.GetHelpString(topic) != null ? topic : null; + } + catch (Exception e) + { + Logger.WriteError("Resolving the row's help topic failed; Help is hidden.", e); + return null; + } + } + + private void ShowDetailHelp(string topic) + => ShowHelp.ShowHelpTopic(m_propertyTable.GetValue("HelpTopicProvider"), topic); + /// /// Locates the row's node in its own compiled model, with the current override applied, /// so visibility checkmarks and move enablement reflect the live state. False without a diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs index dbc261296a..279709f03c 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailObjectCommandExecutionTests.cs @@ -11,6 +11,7 @@ using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; @@ -652,15 +653,20 @@ public void ReorderVectorAuthority_RejectsALeafItDoesNotAnswer() } [Test] - public void LabelMenu_StillNeedsTheAdapter_WhileTheSharedObjectMenuHasNoAuthority() + public void LabelMenu_IsFullyOwned_UnlessItCarriesTheWritingSystemsList() { MakeTwoSubentries(); - var field = SubentriesField(); - var authority = m_view.CreateReorderVectorAuthority(LabelMenuRequest(field, NoItem)); - - Assert.That(XCoreMenuBridge.OwnsAll(authority, new[] { ReorderVectorMenuAuthority.MenuId }), Is.True); - Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(field)), Is.False, - "the label menu merges mnuDataTree-Object, which the mediator still answers"); + var subentries = SubentriesField(); + var authority = m_view.CreateMenuAuthority(LabelMenuRequest(subentries, NoItem)); + + Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(subentries)), Is.True, + "the reorder menu and the shared object menu are both answered natively"); + var helpBound = DetailComposer.Compose(m_entry, Cache).Model.Fields + .First(f => f.MenuId == ObjectMenuAuthority.HelpMenuId); + Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(helpBound)), Is.True, + "a row bound to the empty Help menu merges only owned ids"); + Assert.That(XCoreMenuBridge.OwnsAll(authority, LabelMenuIds(LexemeFormField())), Is.False, + "the multi-string menu's writing-system list still needs the mediator"); Assert.That(XCoreMenuBridge.OwnsAll(null, new[] { ReorderVectorMenuAuthority.MenuId }), Is.False); } @@ -810,6 +816,277 @@ public void OwnedMenu_WithAListSubmenu_IsRefused() "a list-populated submenu has no configured leaves an authority could answer"); } + // The per-object menu's native authority: Field Visibility, Move Field and Help answered + // from the row and the override layer, so an ordinary row's label menu needs no adapter. + + private static IHelpTopicProvider KnowsEveryTopic => new PatternHelpTopicProvider(id => true); + + private void UseHelpProvider(IHelpTopicProvider provider) + { + m_propertyTable.SetProperty("HelpTopicProvider", provider, false); + m_propertyTable.SetPropertyPersistence("HelpTopicProvider", false); + } + + private LegacyDataTree AdapterTree => (LegacyDataTree)GetField(m_view, "m_dataEntryForm"); + + // The label menu exactly as OnDetailMenuRequested builds it: the row's authorities for + // the ids they own, the override interceptor for the rest. + private IReadOnlyList BuildAsTheHostDoes(string[] ids, DetailField field) + { + var registry = new OverrideCommandRegistry(); + m_view.AddOverrideCommands(registry, field); + var window = m_propertyTable.GetValue("window"); + return XCoreMenuBridge.CreateMenuItems(window, ids, registry.TryBuild, null, + m_view.CreateMenuAuthority(LabelMenuRequest(field, NoItem))); + } + + // Whether the adapter's current slice is the WinForms twin of the row: same object and + // same field. + private bool AdapterTargets(DetailField field) + { + var slice = AdapterTree.CurrentSlice; + if (slice?.Object == null || slice.Object.Hvo != field.ObjectHvo || slice.Flid == 0) + return false; + var mdc = (IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor; + return mdc.FieldExists(slice.Flid) + && string.Equals(mdc.GetFieldName(slice.Flid), field.Field, StringComparison.Ordinal); + } + + [Test] + public void ObjectMenuAuthority_AnswersEveryLeafOfItsMenus() + { + var authority = m_view.CreateObjectMenuAuthority(LexemeFormField()); + var window = m_propertyTable.GetValue("window"); + var menu = window.GetContextMenuChoiceGroup(new[] { ObjectMenuAuthority.MenuId }); + menu.PopulateNow(querySubmenuVisibility: false); + + var leaves = Leaves(menu).ToList(); + Assert.That(leaves.Select(l => l.HelpId), Is.EquivalentTo(new[] + { + ObjectMenuAuthority.AlwaysVisibleCommandId, ObjectMenuAuthority.IfDataCommandId, + ObjectMenuAuthority.NormallyHiddenCommandId, ObjectMenuAuthority.MoveFieldUpCommandId, + ObjectMenuAuthority.MoveFieldDownCommandId, ObjectMenuAuthority.HelpCommandId + }), "the shipped menu defines exactly the leaves the authority knows"); + foreach (var leaf in leaves) + { + Assert.That(() => authority.Build(ObjectMenuAuthority.MenuId, leaf), Throws.Nothing, + "the authority does not answer leaf '{0}'", leaf.HelpId); + } + + var help = window.GetContextMenuChoiceGroup(new[] { ObjectMenuAuthority.HelpMenuId }); + help.PopulateNow(querySubmenuVisibility: false); + Assert.That(Leaves(help), Is.Empty, "mnuDataTree-Help defines no leaf of its own"); + Assert.That(authority.Owns(ObjectMenuAuthority.HelpMenuId), Is.True); + } + + [Test] + public void ObjectMenu_OfAnOrdinaryRow_IsBuiltWithoutTheAdapterOrTheMediator() + { + MakeTwoSubentries(); + UseHelpProvider(KnowsEveryTopic); + var field = SubentriesField(); + var ids = LabelMenuIds(field); + // No EnsureAdapter: the hidden tree never exists. + + var items = BuildWithSpy(ids, m_view.CreateMenuAuthority(LabelMenuRequest(field, NoItem)), out var asked); + + Assert.That(asked, Is.False, "nothing in the label menu reaches the mediator"); + var visibility = FindItem(items, "Field Visibility"); + Assert.That(visibility, Is.Not.Null); + Assert.That(visibility.Children.Count(c => c.IsChecked), Is.EqualTo(1), + "exactly one visibility is the row's current one"); + Assert.That(visibility.Children.Select(c => c.Execute), Is.All.Not.Null, + "a located row's visibility items all execute"); + var move = FindItem(items, "Move Field"); + Assert.That(move?.Children.Select(c => c.Label), Is.EqualTo(new[] { "Move Up", "Move Down" })); + Assert.That(FindItem(items, "Help...")?.Execute, Is.Not.Null, "Help opens the row's own topic"); + } + + // Baseline = the path this replaces: mediator through the adapter, interceptor for the + // field commands. An unlocatable row is the agreed exception: its commands disable. + [Test] + public void ObjectMenu_NativeAuthority_RendersWhatTheInterceptorPathRendered_ForEveryRowTheAdapterCanTarget() + { + AddSense("second gloss"); + MakeTwoSubentries(); + UseHelpProvider(KnowsEveryTopic); + var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.ObjectHvo != 0 && !string.IsNullOrEmpty(f.Field)).ToList(); + + var compared = 0; + var untargeted = new List(); + var unlocatable = new List(); + var mismatches = new List(); + foreach (var field in fields) + { + var ids = LabelMenuIds(field); + EnsureAdapter(field.ObjectHvo, field.Field); + if (!AdapterTargets(field)) + { + untargeted.Add(field.Label + " (" + field.Field + ")"); + continue; + } + var native = BuildAsTheHostDoes(ids, field); + if (!m_view.TryLocateOverrideTarget(field, out _, out _)) + { + unlocatable.Add(string.Format("{0} ({1}) stableId={2} class={3} layout={4}", field.Label, + field.Field, field.StableId, field.ClassName, field.LayoutName)); + var fieldItems = FindItem(native, "Field Visibility").Children + .Concat(FindItem(native, "Move Field").Children).ToList(); + Assert.That(fieldItems.Select(i => i.IsEnabled), Is.All.False, + "an unlocatable row disables its field commands: " + field.Label); + continue; + } + compared++; + var before = Describe(BuildItemsWithOverrideInterceptor(ids, field)); + var after = Describe(native); + if (!string.Equals(before, after, StringComparison.Ordinal)) + { + mismatches.Add(string.Format("{0} ({1}):{2}--- interceptor path{2}{3}{2}--- authority{2}{4}", + field.Label, field.Field, Environment.NewLine, before, after)); + } + } + TestContext.WriteLine("Rows the adapter could not target: " + string.Join(", ", untargeted)); + TestContext.WriteLine("Rows with no override target:" + Environment.NewLine + + string.Join(Environment.NewLine, unlocatable)); + Assert.That(compared, Is.GreaterThan(10), "enough rows have an adapter twin to make the comparison meaningful"); + Assert.That(mismatches, Is.Empty, string.Join(Environment.NewLine, mismatches)); + } + + // The adapter finds no slice for the sense's Publish In row and answers Help from another + // slice; the authority resolves the row's own topic. + [Test] + public void Help_OnPublishSenseIn_OpensTheRowsOwnTopic() + { + UseHelpProvider(KnowsEveryTopic); + var sense = m_entry.SensesOS[0]; + var field = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Single(f => f.ObjectHvo == sense.Hvo && f.Field == "PublishIn"); + EnsureAdapter(field.ObjectHvo, field.Field); + TestContext.WriteLine("adapter topic: " + AdapterTree.CurrentSlice?.GetSliceHelpTopicID()); + + var topic = m_view.KnownHelpTopic(field); + + Assert.That(topic, Does.Contain("PublishIn"), "the row's help topic names the row's field"); + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field)); + Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True); + } + + [Test] + public void ObjectMenu_HidesHelp_WhenTheProviderHasNoTopic() + { + // The fixture's default provider knows no topic; WinForms hides Help for that too. + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(LexemeFormField())); + + Assert.That(FindItem(items, "Help..."), Is.Null, "no known topic, no Help item"); + Assert.That(FindItem(items, "Field Visibility"), Is.Not.Null); + } + + // A WinForms slice always ends at a topic, the generic one at worst; so does a row that + // has no object to generate from. + [Test] + public void Help_OnARowWithoutAnObject_OffersTheGenericTopic() + { + UseHelpProvider(new PatternHelpTopicProvider(id => id == FieldHelpTopics.NoHelpTopic)); + var field = new DetailField("Row", "Row", "Nothing", null, DetailFieldKind.Text, + EditorClassification.Known, "Row", null, HostRouting.Inherit, null, null, null); + Assert.That(field.ObjectHvo, Is.EqualTo(0), "precondition: no object, no help source"); + + Assert.That(m_view.ResolveHelpTopic(field), Is.EqualTo(FieldHelpTopics.NoHelpTopic)); + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field)); + Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True, "the generic topic is still a topic"); + } + + [Test] + public void ObjectMenu_OnARowWithoutOverrideContext_DisablesVisibilityAndMove_AndKeepsHelp() + { + UseHelpProvider(KnowsEveryTopic); + // No class or layout: the row's override target cannot be located. + var field = new DetailField("Row", "Row", "Subentries", null, DetailFieldKind.Text, + EditorClassification.Known, "Row", null, HostRouting.Inherit, null, null, null, + objectHvo: m_entry.Hvo) { HelpTopicId = "khtpTest-Row" }; + + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field)); + + var visibility = FindItem(items, "Field Visibility"); + Assert.That(visibility.Children.Select(c => c.IsEnabled), Is.All.False, "nothing to act on: disabled, never guessed"); + Assert.That(visibility.Children.Select(c => c.Execute), Is.All.Null); + Assert.That(FindItem(items, "Move Field").Children.Select(c => c.IsEnabled), Is.All.False); + Assert.That(FindItem(items, "Help...")?.IsEnabled, Is.True, "Help needs no override target"); + } + + [Test] + public void FieldVisibility_ThroughTheAuthority_WritesTheOverride() + { + var field = LexemeFormField(); + DeleteOverrideFor(field); + try + { + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field)); + var item = FindItem(items, "Normally hidden"); + Assert.That(item?.IsEnabled, Is.True); + + item.Execute(); + DrainMediatorAndIdleQueues(); + + var stored = ReadOverrideFor(field); + Assert.That(stored, Is.Not.Null, "choosing a visibility writes a project override"); + var op = stored.Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility); + Assert.That(op.StableId, Is.EqualTo(ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId))); + } + finally + { + DeleteOverrideFor(field); + } + } + + // A sense with no examples composes a ghost Example prompt whose stable id carries the + // ghost marker; its field commands act on the Examples sequence node itself. + [Test] + public void GhostRow_LocatesItsSequenceNode_AndItsVisibilityWritesThatNode() + { + var sense = m_entry.SensesOS[0]; + Assert.That(sense.ExamplesOS, Is.Empty, "precondition: the sense composes a ghost Example row"); + var field = DetailComposer.Compose(m_entry, Cache).Model.Fields.Single(f => + f.ObjectHvo == sense.Hvo && f.Field == "Examples" + && f.StableId.EndsWith(DetailField.GhostStableIdSuffix, StringComparison.Ordinal)); + DeleteOverrideFor(field); + var nodeId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId); + nodeId = nodeId.Substring(0, nodeId.Length - DetailField.GhostStableIdSuffix.Length); + + Assert.That(m_view.TryLocateOverrideTarget(field, out var templateId, out _), Is.True, + "the ghost row locates the node it stands in for"); + Assert.That(templateId, Is.EqualTo(nodeId)); + + try + { + var items = BuildItems(new[] { ObjectMenuAuthority.MenuId }, m_view.CreateObjectMenuAuthority(field)); + FindItem(items, "Normally hidden").Execute(); + DrainMediatorAndIdleQueues(); + + var op = ReadOverrideFor(field).Operations.Single(o => o.Kind == ViewOverrideOperationKind.SetVisibility); + Assert.That(op.StableId, Is.EqualTo(nodeId), "the override names the sequence node, not the ghost"); + } + finally + { + DeleteOverrideFor(field); + } + } + + [Test] + public void ObjectMenuAuthority_RejectsALeafItDoesNotAnswer() + { + var authority = m_view.CreateObjectMenuAuthority(LexemeFormField()); + var window = m_propertyTable.GetValue("window"); + var reorderMenu = window.GetContextMenuChoiceGroup(new[] { ReorderVectorMenuAuthority.MenuId }); + reorderMenu.PopulateNow(); + var foreignLeaf = reorderMenu.OfType() + .First(c => c.HelpId == ReorderVectorMenuAuthority.AlphabeticalOrderCommandId); + + Assert.That(() => authority.Build(ObjectMenuAuthority.MenuId, foreignLeaf), + Throws.InvalidOperationException, "an owned id must be answered in full, never partially"); + } + // ---------------------------------------------------------------------------------------- // Delete Sense / Delete object // ---------------------------------------------------------------------------------------- @@ -1325,9 +1602,8 @@ public void MoveField_OnATopLevelRow_ReordersTheRootList() + $"index={location.Index} up={up}"); try { - EnsureAdapter(field.ObjectHvo); - var items = BuildItemsWithOverrideInterceptor( - new[] { "mnuDataTree-Object" }, field); + // No EnsureAdapter: the authority answers Move Field from the row alone. + var items = BuildAsTheHostDoes(new[] { ObjectMenuAuthority.MenuId }, field); var item = FindItem(items, up ? "Move Up" : "Move Down"); Assert.That(item, Is.Not.Null, "the Move Field submenu must offer the item"); Assert.That(item.IsEnabled, Is.True,