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,