From fe5b5ff243120545d47cceb2b6e396f05b6dd2a2 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:31:23 -0500 Subject: [PATCH] LT-22691: Answer the Category Edit menus natively The Avalonia detail view answers every Grammar > Category Edit menu itself, and the Insert menu's category commands. Until now Duplicate and Move on an affix slot or template, and Insert Subcategory, needed the hidden DataTree, where Move could not show its category list; the Insert menu's commands showed only once a right-click had built that tree. Move asks for the category with the Avalonia chooser dialog, and Duplicate is one undoable step in the New UI. In both views a move's undo text is the command's own ("Undo Move Affix Slot") in place of fixed English, and moving an item to the category it is in does nothing. The move, duplicate and subcategory rules are shared with WinForms in DetailRules. Inserting a list item's sub-item is answered natively too, which covers the Subitems menu of Exception "Features" and the Lists area's sub-item menus. Menus and commands now answered natively: * mnuDataTree-POS-AffixSlot, mnuDataTree-POS-AffixTemplate, mnuDataTree-POS-SubPossibilities * mnuDataTree-SubPossibilities, mnuDataTree-SubSemanticDomain, mnuDataTree-SubComplexEntryType, mnuDataTree-SubVariantEntryType, mnuDataTree-SubAnthroCategory, mnuDataTree-SubLocation * CmdDataTree-Insert-POS-SubPossibilities, CmdDataTree-Insert-POS-AffixTemplate, CmdDataTree-Insert-POS-AffixSlot, CmdDataTree-Insert-POS-InflectionClass (Category Edit) Co-Authored-By: Claude Fable 5.2 --- Src/Common/Controls/DetailControls/Slice.cs | 7 +- Src/FdoUi/DetailRules/CategoryItemMove.cs | 91 +++++ .../DetailRules/DetailObjectDuplication.cs | 76 ++++ .../DetailRules/DetailObjectInsertion.cs | 31 ++ .../DetailRules/CategoryItemMoveTests.cs | 149 +++++++ .../DetailObjectDuplicationTests.cs | 112 ++++++ .../DetailRules/DetailObjectInsertionTests.cs | 79 ++++ .../Avalonia/LcmCategoryChooserLauncher.cs | 91 +++++ .../LcmCategoryChooserLauncherTests.cs | 68 ++++ .../Hosting/RecordEditView.CategoryMenus.cs | 125 ++++++ .../Hosting/RecordEditView.LexiconMenus.cs | 18 +- .../Hosting/RecordEditView.MainMenus.cs | 11 +- Src/xWorks/DTMenuHandler.cs | 105 +---- .../Avalonia/Hosting/CategoryEditMenuTests.cs | 376 ++++++++++++++++++ .../Hosting/DataTreeMoveReachabilityTests.cs | 39 +- .../Avalonia/Hosting/DetailMenuTestSupport.cs | 163 ++++++++ .../Hosting/LexiconMenuAuthorityTests.cs | 184 ++------- .../Avalonia/Hosting/NotebookMenuTests.cs | 67 +--- 18 files changed, 1449 insertions(+), 343 deletions(-) create mode 100644 Src/FdoUi/DetailRules/CategoryItemMove.cs create mode 100644 Src/FdoUi/DetailRules/DetailObjectDuplication.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs create mode 100644 Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs create mode 100644 Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs create mode 100644 Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/DetailMenuTestSupport.cs diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs index bae37ac5fc..a364d0f9c3 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -2465,14 +2465,11 @@ public virtual void HandleCopyCommand(Slice newSlice, string label) if (newObj == null) throw new ConfigurationException("NewSlice:GetObjectHvoForMenusToOperateOn is either messed up or should not have been called, because it could not find the object to be moved to a copy of its owner.", m_configurationNode); - if (origObj is ICloneableCmObject) + if (DetailObjectDuplication.CanCopy(origObj)) { string undoMsg = String.Format("Undo {0}", label); string redoMsg = String.Format("Redo {0}", label); - UndoableUnitOfWorkHelper.Do(undoMsg, redoMsg, m_cache.ActionHandlerAccessor, - () => { - ((ICloneableCmObject)origObj).SetCloneProperties(newObj); - }); + DetailObjectDuplication.CopyProperties(origObj, newObj, undoMsg, redoMsg); } else { diff --git a/Src/FdoUi/DetailRules/CategoryItemMove.cs b/Src/FdoUi/DetailRules/CategoryItemMove.cs new file mode 100644 index 0000000000..3b458a4145 --- /dev/null +++ b/Src/FdoUi/DetailRules/CategoryItemMove.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +// 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 Move command of an affix slot or an affix template (message DataTreeMove): the + /// category that holds it, and its move to another category. + /// + public static class CategoryItemMove + { + /// + /// The category that owns when it is an affix slot or an + /// affix template; null for anything else. + /// + public static IPartOfSpeech CategoryOf(ICmObject target) + { + if (target == null || !target.IsValidObject) + return null; + return target is IMoInflAffixSlot || target is IMoInflAffixTemplate ? target.Owner as IPartOfSpeech : null; + } + + /// + /// Moves the slot or template to in one undoable step. + /// The affixes of a moved slot take the new category. A template moved to the category + /// that owns its own takes along its slots from its old category, except a slot whose + /// name the new category or one above it already uses. + /// + /// True when it moved; false for the category it is already in, and for an + /// object that is no slot or template. + public static bool Move(ICmObject target, IPartOfSpeech destination, string undoText, string redoText) + { + var from = CategoryOf(target); + if (from == null || destination == null || !destination.IsValidObject || from == destination) + return false; + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW(undoText, redoText, target.Cache.ActionHandlerAccessor, () => + { + if (target is IMoInflAffixSlot slot) + MoveSlot(slot, destination); + else + MoveTemplate((IMoInflAffixTemplate)target, from, destination); + }); + return true; + } + + /// + /// The affix slot named that or a + /// category above it offers; null when none does. + /// + public static IMoInflAffixSlot SlotNamed(IPartOfSpeech category, string name) + { + for (var pos = category; pos != null; pos = pos.Owner as IPartOfSpeech) + { + // BestAnalysisVernacularAlternative always returns something. + var match = pos.AllAffixSlots.FirstOrDefault(s => s.Name.BestAnalysisVernacularAlternative.Text == name); + if (match != null) + return match; + } + return null; + } + + private static void MoveSlot(IMoInflAffixSlot slot, IPartOfSpeech destination) + { + destination.AffixSlotsOC.Add(slot); + foreach (var msa in slot.Affixes.ToList()) + msa.PartOfSpeechRA = destination; + } + + private static void MoveTemplate(IMoInflAffixTemplate template, IPartOfSpeech from, IPartOfSpeech destination) + { + destination.AffixTemplatesOS.Add(template); + if (from.Owner != destination) + return; + foreach (var slot in template.PrefixSlotsRS.Concat(template.SuffixSlotsRS).ToList()) + { + if (slot.Owner == from + && SlotNamed(destination, slot.Name.BestAnalysisVernacularAlternative.Text) == null) + { + MoveSlot(slot, destination); + } + } + } + } +} diff --git a/Src/FdoUi/DetailRules/DetailObjectDuplication.cs b/Src/FdoUi/DetailRules/DetailObjectDuplication.cs new file mode 100644 index 0000000000..4619314873 --- /dev/null +++ b/Src/FdoUi/DetailRules/DetailObjectDuplication.cs @@ -0,0 +1,76 @@ +// 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 SIL.LCModel; +using SIL.LCModel.Core.Cellar; +using SIL.LCModel.Infrastructure; + +// 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 Duplicate command of a detail row (message DataTreeCopy): whether an object can be + /// duplicated, the copy of its properties onto another object, and the whole duplicate. + /// + public static class DetailObjectDuplication + { + /// Whether can copy its properties onto another + /// object of its class. + public static bool CanCopy(ICmObject original) + => original != null && original.IsValidObject && original is ICloneableCmObject; + + /// + /// Whether can be duplicated: it can copy its properties + /// and sits in an owning sequence or collection, which takes the copy. + /// + public static bool CanDuplicate(ICmObject original) + { + if (!CanCopy(original) || original.Owner == null) + return false; + switch ((CellarPropertyType)original.Cache.MetaDataCacheAccessor.GetFieldType(original.OwningFlid)) + { + case CellarPropertyType.OwningSequence: + case CellarPropertyType.OwningCollection: + return true; + default: + return false; + } + } + + /// + /// Copies the properties of onto + /// in one undoable step. + /// + public static void CopyProperties(ICmObject original, ICmObject copy, string undoText, string redoText) + { + UndoableUnitOfWorkHelper.Do(undoText, redoText, original.Cache.ActionHandlerAccessor, + () => ((ICloneableCmObject)original).SetCloneProperties(copy)); + } + + /// + /// Duplicates in one undoable step: a new object of its + /// class beside it, right after it in a sequence, takes a copy of its properties. The + /// copy is created as the model creates it, with no step a class adds to its Insert. + /// + /// The copy; null when refuses. + public static ICmObject Duplicate(ICmObject original, string undoText, string redoText) + { + if (!CanDuplicate(original)) + return null; + var cache = original.Cache; + var owner = original.Owner; + var flid = original.OwningFlid; + var position = DetailObjectInsertion.InsertionPosition(owner, flid, new[] { original.Hvo }); + ICmObject copy = null; + UndoableUnitOfWorkHelper.Do(undoText, redoText, cache.ActionHandlerAccessor, () => + { + var hvo = cache.DomainDataByFlid.MakeNewObject(original.ClassID, owner.Hvo, flid, position); + copy = cache.ServiceLocator.ObjectRepository.GetObject(hvo); + ((ICloneableCmObject)original).SetCloneProperties(copy); + }); + return copy; + } + } +} diff --git a/Src/FdoUi/DetailRules/DetailObjectInsertion.cs b/Src/FdoUi/DetailRules/DetailObjectInsertion.cs index 068ec3db3f..e5f5391f32 100644 --- a/Src/FdoUi/DetailRules/DetailObjectInsertion.cs +++ b/Src/FdoUi/DetailRules/DetailObjectInsertion.cs @@ -29,6 +29,9 @@ public static class DetailObjectInsertion /// The entry's virtual field of its variants' entry refs. public const string VariantBackRefField = "VariantFormEntryBackRefs"; + /// The field that holds the sub-items of a list item. + public const string SubItemsField = "SubPossibilities"; + /// /// Whether a new object can go into of /// : a vector, a virtual reference vector, or an empty atomic @@ -103,6 +106,19 @@ public static bool CanInsert(ICmObject root, ICmObject rowObject, string command return CanInsert(root, rowObject, fieldName, out index); } + /// + /// Whether the shown record takes what a command inserts: a command that inserts a + /// sub-item applies only to a shown record of exactly its class, so each list offers + /// its own kind of sub-item. Any other command applies. + /// + /// The shown record. + public static bool OffersSubItem(ICmObject root, string fieldName, string className) + { + if (fieldName != SubItemsField) + return true; + return root != null && root.IsValidObject && root.ClassName == className; + } + /// /// Whether some class owns objects in a field named , so /// an Insert into it creates an object rather than choosing one. @@ -314,6 +330,21 @@ public static DetailInsertTarget ResolveTarget(ICmObject rowObject, string field return null; } + /// + /// The target of a row's Insert command in the view of : as + /// , except that + /// none resolves when refuses the command, and a row of + /// the shown record itself never inserts at its owner, which the view does not show. + /// + /// The shown record. + public static DetailInsertTarget ResolveTarget(ICmObject root, ICmObject rowObject, string fieldName, + string className, string ownerClassName, bool insertAtOwner) + { + if (!OffersSubItem(root, fieldName, className)) + return null; + return ResolveTarget(rowObject, fieldName, className, ownerClassName, insertAtOwner && rowObject != root); + } + // The object and its owners, nearest first. private static IEnumerable OwnerChainHvos(ICmObject obj) { diff --git a/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs new file mode 100644 index 0000000000..fee4bf1c32 --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/CategoryItemMoveTests.cs @@ -0,0 +1,149 @@ +// 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 NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The Move rule of an affix slot and an affix template: which category holds one, what + /// follows it to another category, and that each move is one undoable step. + /// + [TestFixture] + public class CategoryItemMoveTests : MemoryOnlyBackendProviderTestBase + { + private const string Undo = "Undo Move"; + private const string Redo = "Redo Move"; + + private IPartOfSpeech _verb; + private IPartOfSpeech _irregular; + private IPartOfSpeech _noun; + private IMoInflAffixSlot _slot; + private IMoInflAffixTemplate _template; + private ILexEntry _entry; + private IMoInflAffMsa _affix; + + // Verb with the subcategory Irregular, and Noun. Irregular owns the slot Tense, a + // template that uses it as a prefix slot, and is the category of one affix in the slot. + [SetUp] + public void MakeCategories() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var services = Cache.ServiceLocator; + var categories = services.GetInstance(); + var list = Cache.LangProject.PartsOfSpeechOA; + list.PossibilitiesOS.Add(_verb = categories.Create()); + _verb.SubPossibilitiesOS.Add(_irregular = categories.Create()); + list.PossibilitiesOS.Add(_noun = categories.Create()); + _irregular.AffixSlotsOC.Add(_slot = services.GetInstance().Create()); + _slot.Name.SetAnalysisDefaultWritingSystem("Tense"); + _irregular.AffixTemplatesOS.Add(_template = services.GetInstance().Create()); + _template.PrefixSlotsRS.Add(_slot); + _entry = services.GetInstance().Create(); + _entry.MorphoSyntaxAnalysesOC.Add(_affix = services.GetInstance().Create()); + _affix.PartOfSpeechRA = _irregular; + _affix.SlotsRC.Add(_slot); + }); + } + + [TearDown] + public void DeleteCategories() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var obj in new ICmObject[] { _entry, _verb, _noun }) + { + if (obj.IsValidObject) + obj.Delete(); + } + }); + } + + [Test] + public void CategoryOf_IsTheOwnerOfASlotOrATemplate_AndNullForAnythingElse() + { + Assert.That(CategoryItemMove.CategoryOf(_slot), Is.SameAs(_irregular)); + Assert.That(CategoryItemMove.CategoryOf(_template), Is.SameAs(_irregular)); + Assert.That(CategoryItemMove.CategoryOf(_irregular), Is.Null, "a category is no slot or template"); + Assert.That(CategoryItemMove.CategoryOf(null), Is.Null); + } + + [Test] + public void Move_ASlot_PutsItInTheCategory_WithItsAffixes_InOneUndoableStep() + { + Assert.That(CategoryItemMove.Move(_slot, _noun, Undo, Redo), Is.True); + + Assert.That(_slot.Owner, Is.SameAs(_noun)); + Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_noun), "the slot's affix takes the new category"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_slot.Owner, Is.SameAs(_irregular), "one Undo"); + Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_irregular)); + } + + [Test] + public void Move_ToTheCategoryItIsIn_OrOfAnotherKindOfObject_DoesNothing() + { + var steps = Cache.ActionHandlerAccessor.UndoableSequenceCount; + + Assert.That(CategoryItemMove.Move(_slot, _irregular, Undo, Redo), Is.False); + Assert.That(CategoryItemMove.Move(_template, _irregular, Undo, Redo), Is.False); + Assert.That(CategoryItemMove.Move(_irregular, _noun, Undo, Redo), Is.False, "a category is no slot or template"); + Assert.That(CategoryItemMove.Move(_slot, null, Undo, Redo), Is.False); + + Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(steps), "no undoable step"); + } + + [Test] + public void Move_ATemplateToTheCategoryAbove_TakesItsSlotAlong_InOneUndoableStep() + { + Assert.That(CategoryItemMove.Move(_template, _verb, Undo, Redo), Is.True); + + Assert.That(_template.Owner, Is.SameAs(_verb)); + Assert.That(_slot.Owner, Is.SameAs(_verb), "the slot it uses follows"); + Assert.That(_affix.PartOfSpeechRA, Is.SameAs(_verb)); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_template.Owner, Is.SameAs(_irregular), "one Undo"); + Assert.That(_slot.Owner, Is.SameAs(_irregular)); + } + + [Test] + public void Move_ATemplateToTheCategoryAbove_LeavesASlotWhoseNameIsUsedThere() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var taken = Cache.ServiceLocator.GetInstance().Create(); + _verb.AffixSlotsOC.Add(taken); + taken.Name.SetAnalysisDefaultWritingSystem("Tense"); + }); + + Assert.That(CategoryItemMove.Move(_template, _verb, Undo, Redo), Is.True); + + Assert.That(_template.Owner, Is.SameAs(_verb)); + Assert.That(_slot.Owner, Is.SameAs(_irregular), "Verb already has a slot named Tense"); + } + + [Test] + public void Move_ATemplateToAnotherCategoryThanTheOneAbove_LeavesItsSlots() + { + Assert.That(CategoryItemMove.Move(_template, _noun, Undo, Redo), Is.True); + + Assert.That(_template.Owner, Is.SameAs(_noun)); + Assert.That(_slot.Owner, Is.SameAs(_irregular)); + } + + [Test] + public void SlotNamed_FindsASlotOfTheCategoryOrOneAboveIt() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => _verb.AffixSlotsOC.Add(_slot)); + + Assert.That(CategoryItemMove.SlotNamed(_irregular, "Tense"), Is.SameAs(_slot), "inherited from Verb"); + Assert.That(CategoryItemMove.SlotNamed(_verb, "Tense"), Is.SameAs(_slot)); + Assert.That(CategoryItemMove.SlotNamed(_noun, "Tense"), Is.Null); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs new file mode 100644 index 0000000000..eb02d3d9ee --- /dev/null +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectDuplicationTests.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.DetailRules; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.FdoUi +{ + /// + /// The Duplicate rule of a detail row: which objects it applies to, where the copy goes, + /// and that the whole duplicate is one undoable step. + /// + [TestFixture] + public class DetailObjectDuplicationTests : MemoryOnlyBackendProviderTestBase + { + private const string Undo = "Undo Duplicate"; + private const string Redo = "Redo Duplicate"; + + private IPartOfSpeech _category; + private IMoInflAffixSlot _slot; + private IMoInflAffixTemplate _first; + private IMoInflAffixTemplate _second; + private ILexEntry _entry; + + // A category with the slot Tense and two templates; the first uses the slot. + [SetUp] + public void MakeCategory() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var services = Cache.ServiceLocator; + Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add( + _category = services.GetInstance().Create()); + _category.AffixSlotsOC.Add(_slot = services.GetInstance().Create()); + _slot.Name.SetAnalysisDefaultWritingSystem("Tense"); + var templates = services.GetInstance(); + _category.AffixTemplatesOS.Add(_first = templates.Create()); + _first.Name.SetAnalysisDefaultWritingSystem("Finite"); + _first.PrefixSlotsRS.Add(_slot); + _category.AffixTemplatesOS.Add(_second = templates.Create()); + _entry = services.GetInstance().Create(); + }); + } + + [TearDown] + public void DeleteCategory() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var obj in new ICmObject[] { _entry, _category }) + { + if (obj.IsValidObject) + obj.Delete(); + } + }); + } + + [Test] + public void CanDuplicate_AnObjectThatCopiesItself_InAnOwningVector() + { + Assert.That(DetailObjectDuplication.CanDuplicate(_slot), Is.True, "in an owning collection"); + Assert.That(DetailObjectDuplication.CanDuplicate(_first), Is.True, "in an owning sequence"); + Assert.That(DetailObjectDuplication.CanDuplicate(_entry), Is.False, "an entry does not copy itself"); + Assert.That(DetailObjectDuplication.CanDuplicate(null), Is.False); + Assert.That(DetailObjectDuplication.Duplicate(_entry, Undo, Redo), Is.Null); + } + + [Test] + public void Duplicate_ATemplate_PutsTheCopyRightAfterIt_InOneUndoableStep() + { + var copy = (IMoInflAffixTemplate)DetailObjectDuplication.Duplicate(_first, Undo, Redo); + + Assert.That(_category.AffixTemplatesOS.ToList(), Is.EqualTo(new[] { _first, copy, _second })); + Assert.That(copy.PrefixSlotsRS.ToList(), Is.EqualTo(new[] { _slot }), "the copy uses the same slot"); + Assert.That(copy.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Finite2"), "the copy's name is numbered"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_category.AffixTemplatesOS.ToList(), Is.EqualTo(new[] { _first, _second }), "one Undo"); + } + + [Test] + public void Duplicate_ASlot_AddsTheCopyToItsCategory_InOneUndoableStep() + { + var copy = (IMoInflAffixSlot)DetailObjectDuplication.Duplicate(_slot, Undo, Redo); + + Assert.That(_category.AffixSlotsOC, Is.EquivalentTo(new[] { _slot, copy })); + Assert.That(_slot.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense"), "the original keeps its name"); + Assert.That(copy.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense2"), "the copy's name is numbered"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(_category.AffixSlotsOC, Is.EquivalentTo(new[] { _slot }), "one Undo"); + } + + [Test] + public void CopyProperties_CopiesOntoAnExistingObject_InItsOwnUndoableStep() + { + IMoInflAffixSlot other = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => _category.AffixSlotsOC.Add(other = Cache.ServiceLocator.GetInstance().Create())); + Assert.That(DetailObjectDuplication.CanCopy(_slot), Is.True); + Assert.That(DetailObjectDuplication.CanCopy(_entry), Is.False); + + DetailObjectDuplication.CopyProperties(_slot, other, Undo, Redo); + + Assert.That(other.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense2")); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(other.Name.AnalysisDefaultWritingSystem.Text, Is.Null.Or.Empty, "one Undo"); + } + } +} diff --git a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs index 7cf6d98358..6d0e74d3d7 100644 --- a/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs +++ b/Src/FdoUi/FdoUiTests/DetailRules/DetailObjectInsertionTests.cs @@ -2,6 +2,8 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using SIL.FieldWorks.Common.DetailRules; using SIL.LCModel; @@ -25,6 +27,7 @@ public class DetailObjectInsertionTests : MemoryOnlyBackendProviderTestBase private ILexExtendedNote _note2; private ILexEtymology _etymology1; private ILexEtymology _etymology2; + private readonly List _categories = new List(); // A stem entry with two senses, two etymologies, and on the first sense one example // and two extended notes. @@ -62,7 +65,10 @@ public void DeleteEntry() { if (_entry.IsValidObject) _entry.Delete(); + foreach (var category in _categories.Where(c => c.IsValidObject)) + category.Delete(); }); + _categories.Clear(); } [Test] @@ -356,5 +362,78 @@ public void ResolveTarget_IsNull_ForAReferenceField_AFilledAtomicField_AnUnknown "nothing from the etymology up to the entry has ExtendedNote"); Assert.That(DetailObjectInsertion.ResolveTarget(null, "Senses", "LexSense", null, false), Is.Null); } + + [Test] + public void OffersSubItem_OnlyToAShownRecordOfTheCommandsClass() + { + var category = MakeCategory(null); + + Assert.That(DetailObjectInsertion.OffersSubItem(category, "SubPossibilities", "PartOfSpeech"), Is.True); + Assert.That(DetailObjectInsertion.OffersSubItem(category, "SubPossibilities", "CmPossibility"), Is.False, + "another list's kind of sub-item"); + Assert.That(DetailObjectInsertion.OffersSubItem(null, "SubPossibilities", "PartOfSpeech"), Is.False); + Assert.That(DetailObjectInsertion.OffersSubItem(_entry, "Senses", "PartOfSpeech"), Is.True, + "a command that inserts no sub-item applies to any record"); + } + + // The Subcategory command asks for the owner; a row of the shown category itself + // inserts under that category all the same, top-level or nested. + [Test] + public void ResolveTarget_ASubcategoryFromARowOfTheShownCategory_GoesUnderIt() + { + var top = MakeCategory(null); + var nested = MakeCategory(top); + + foreach (var shown in new[] { top, nested }) + { + var target = DetailObjectInsertion.ResolveTarget(shown, shown, "SubPossibilities", "PartOfSpeech", null, + insertAtOwner: true); + + Assert.That(target.Owner, Is.SameAs(shown)); + Assert.That(target.Flid, Is.EqualTo(CmPossibilityTags.kflidSubPossibilities)); + Assert.That(target.ClassId, Is.EqualTo(PartOfSpeechTags.kClassId)); + } + } + + [Test] + public void ResolveTarget_ASubcategoryFromARowOfASubcategory_GoesRightAfterIt() + { + var top = MakeCategory(null); + var first = MakeCategory(top); + MakeCategory(top); + + var target = DetailObjectInsertion.ResolveTarget(top, first, "SubPossibilities", "PartOfSpeech", null, + insertAtOwner: true); + + Assert.That(target.Owner, Is.SameAs(top)); + Assert.That(target.Position, Is.EqualTo(1), "after the first subcategory, before the second"); + } + + [Test] + public void ResolveTarget_ASubItemOfAnotherClassThanTheShownRecord_ResolvesNone() + { + var category = MakeCategory(null); + + Assert.That(DetailObjectInsertion.ResolveTarget(category, category, "SubPossibilities", "CmPossibility", null, false), + Is.Null); + Assert.That(DetailObjectInsertion.ResolveTarget(_entry, _sense1, "Senses", "LexSense", "LexSense", false).Owner, + Is.SameAs(_sense1), "any other command resolves as without a shown record"); + } + + // A category at the top of the list, or under another; deleted with the test. + private IPartOfSpeech MakeCategory(IPartOfSpeech owner) + { + IPartOfSpeech category = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + category = Cache.ServiceLocator.GetInstance().Create(); + if (owner == null) + Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(category); + else + owner.SubPossibilitiesOS.Add(category); + }); + _categories.Add(category); + return category; + } } } diff --git a/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs b/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs new file mode 100644 index 0000000000..e3de342265 --- /dev/null +++ b/Src/LexText/LexTextControls/Avalonia/LcmCategoryChooserLauncher.cs @@ -0,0 +1,91 @@ +// 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.Linq; +using System.Windows.Forms; +using FwAvaloniaDialogs; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.LCModel; +using AvControl = Avalonia.Controls.Control; + +namespace SIL.FieldWorks.LexText.Controls +{ + /// + /// Asks the user for one of the project's categories with the chooser dialog, shown as + /// the category tree with nothing selected. + /// + public sealed class LcmCategoryChooserLauncher + : AvaloniaDialogLauncher + { + private readonly LcmCache _cache; + private readonly string _title; + private ChooserDialogViewModel _viewModel; + + private LcmCategoryChooserLauncher(LcmCache cache, string title) + { + _cache = cache; + _title = title; + } + + /// + /// Shows the project's categories over . + /// + /// The dialog's title. + /// The chosen category; null when the user cancels. + public static IPartOfSpeech Choose(LcmCache cache, IWin32Window owner, string title) + { + if (cache == null) + throw new ArgumentNullException(nameof(cache)); + var outcome = new LcmCategoryChooserLauncher(cache, title).Run(owner); + return outcome.Accepted ? outcome.Payload : null; + } + + protected override string DialogTitle => _title; + protected override bool Resizable => true; + protected override int DialogWidth => 420; + protected override int DialogHeight => 460; + + protected override ChooserDialogInput BuildState() => BuildInput(_cache); + + /// + /// The chooser's input: every category as a tree, with one choice required before OK. + /// + internal static ChooserDialogInput BuildInput(LcmCache cache) + { + return new ChooserDialogInput + { + Candidates = LcmInsertEntryDialogLauncher.BuildPosNodes(cache) + .Select(n => new DetailChoiceOption(n.Id, n.Name, n.Depth)).ToList(), + SelectionMode = ChooserSelectionMode.Single, + InitialSelectedKeys = Array.Empty(), + AllowEmpty = false, + Hierarchical = true, + ForbidEmptySelection = true + }; + } + + /// The category a chosen key names; null for no key or one that names + /// none. + internal static IPartOfSpeech Resolve(LcmCache cache, string key) + { + return Guid.TryParse(key, out var guid) + && cache.ServiceLocator.GetInstance().TryGetObject(guid, out var category) + ? category + : null; + } + + protected override ChooserDialogViewModel CreateViewModel(ChooserDialogInput state) + { + _viewModel = new ChooserDialogViewModel(state); + return _viewModel; + } + + protected override AvControl CreateView(ChooserDialogViewModel viewModel) + => new ChooserDialogView { DataContext = viewModel }; + + protected override IPartOfSpeech Apply(ChooserDialogInput state) + => Resolve(_cache, _viewModel?.ChosenKeys?.FirstOrDefault()); + } +} diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs new file mode 100644 index 0000000000..69d1d43c69 --- /dev/null +++ b/Src/LexText/LexTextControls/LexTextControlsTests/Avalonia/LcmCategoryChooserLauncherTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using FwAvaloniaDialogs; +using NUnit.Framework; +using SIL.FieldWorks.LexText.Controls; +using SIL.LCModel; + +namespace LexTextControlsTests +{ + /// + /// The category chooser's input and result: the project's categories as a tree with a + /// choice required, and the category a chosen key names. The modal loop itself is not + /// run here. + /// + [TestFixture] + public class LcmCategoryChooserLauncherTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private IPartOfSpeech m_verb; + private IPartOfSpeech m_irregular; + private IPartOfSpeech m_noun; + + // Verb with the subcategory Irregular, then Noun; the base's open unit of work takes + // the edits. + private void MakeCategories() + { + var categories = Cache.ServiceLocator.GetInstance(); + var list = Cache.LangProject.PartsOfSpeechOA; + list.PossibilitiesOS.Add(m_verb = categories.Create()); + m_verb.Name.SetAnalysisDefaultWritingSystem("Verb"); + m_verb.SubPossibilitiesOS.Add(m_irregular = categories.Create()); + m_irregular.Name.SetAnalysisDefaultWritingSystem("Irregular"); + list.PossibilitiesOS.Add(m_noun = categories.Create()); + m_noun.Name.SetAnalysisDefaultWritingSystem("Noun"); + } + + [Test] + public void BuildInput_OffersEveryCategoryAsATree_WithNothingSelected_AndRequiresAChoice() + { + MakeCategories(); + + var input = LcmCategoryChooserLauncher.BuildInput(Cache); + + var mine = input.Candidates.Where(c => new[] { "Verb", "Irregular", "Noun" }.Contains(c.Name)).ToList(); + Assert.That(mine.Select(c => c.Name), Is.EqualTo(new[] { "Verb", "Irregular", "Noun" }), "list order"); + Assert.That(mine.Select(c => c.Depth), Is.EqualTo(new[] { 0, 1, 0 })); + Assert.That(input.Hierarchical, Is.True); + Assert.That(input.SelectionMode, Is.EqualTo(ChooserSelectionMode.Single)); + Assert.That(input.AllowEmpty, Is.False); + Assert.That(input.ForbidEmptySelection, Is.True); + Assert.That(input.InitialSelectedKeys, Is.Empty, "OK waits for a choice"); + } + + [Test] + public void Resolve_ReturnsTheCategoryAKeyNames_AndNullForNoneOrAnother() + { + MakeCategories(); + var key = LcmCategoryChooserLauncher.BuildInput(Cache).Candidates.Single(c => c.Name == "Noun").Key; + + Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, key), Is.SameAs(m_noun)); + Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, null), Is.Null); + Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, "not a guid"), Is.Null); + Assert.That(LcmCategoryChooserLauncher.Resolve(Cache, System.Guid.NewGuid().ToString()), Is.Null); + } + } +} diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs new file mode 100644 index 0000000000..597e13bf8b --- /dev/null +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.CategoryMenus.cs @@ -0,0 +1,125 @@ +// 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.Windows.Forms; +using SIL.FieldWorks.Common.DetailRules; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.LexText.Controls; +using SIL.LCModel; +using SIL.Reporting; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The host half of Category Edit's item menus: Duplicate and Move on the rows of an affix + /// slot or an affix template. + /// + public partial class RecordEditView + { + /// The tool whose Insert menu commands this view answers for a + /// category. + internal const string CategoryEditTool = "posEdit"; + + /// The message every Duplicate leaf of the detail menus sends. + internal const string DuplicateMessage = "DataTreeCopy"; + + /// The message of Move Affix Slot and Move Affix Template. + internal const string MoveToCategoryMessage = "DataTreeMove"; + + /// + /// Picks the category to move a slot or template to, given the category it is in and + /// the command's label, or null to cancel: the product asks with + /// ; a headless test answers for the user. + /// + internal Func MoveDestinationChosen { get; set; } + + /// + /// The Duplicate item for the row's object: absent on a row with no object, a ghost + /// row and a row whose object the command does not act on; disabled when + /// refuses the object. + /// + internal DetailMenuItem DuplicateItem(DetailMenuLeaf leaf, DetailField field) + { + var target = RowObjectOf(field); + if (target == null || field.GhostPrompt != null || !CommandActsOn(target, leaf.Command)) + return null; + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + if (!DetailObjectDuplication.CanDuplicate(target)) + return DetailMenuItem.Disabled(label); + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => DuplicateDetailObject(field, leaf.Command)); + } + + /// + /// Duplicates the row's object beside it: pending edits settle first, the copy is one + /// undoable step, and the re-show focuses the copy's first row. + /// + /// The copy, or null when nothing was duplicated. + internal ICmObject DuplicateDetailObject(DetailField field, Command command) + { + SettleDetailEdits(); + var copy = DetailObjectDuplication.Duplicate(RowObjectOf(field), command.UndoText, command.RedoText); + if (copy == null) + { + Logger.WriteEvent("Duplicate did nothing: the row's object " + field?.ObjectHvo + " cannot be duplicated."); + return null; + } + m_avaloniaEntryForm?.FocusObjectOnNextShow(copy.Hvo); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return copy; + } + + /// + /// The Move item for the row's affix slot or affix template: absent on a row with no + /// object, a ghost row, and a row whose object the command does not act on or no + /// category owns; enabled otherwise. + /// + internal DetailMenuItem MoveToCategoryItem(DetailMenuLeaf leaf, DetailField field) + { + var target = RowObjectOf(field); + if (target == null || field.GhostPrompt != null || !CommandActsOn(target, leaf.Command) + || CategoryItemMove.CategoryOf(target) == null) + { + return null; + } + var label = XCoreMenuBridge.StripAccelerator(leaf.Label); + return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null, + execute: () => MoveToCategory(field, leaf.Command, label)); + } + + /// + /// Moves the row's slot or template to the category the user picks through + /// : pending edits settle first, the move is one + /// undoable step, and the re-show focuses the nearest row that is left. + /// + /// The title of the dialog that asks for the category. + /// True when the object moved. + internal bool MoveToCategory(DetailField field, Command command, string title) + { + SettleDetailEdits(); + var target = RowObjectOf(field); + var current = CategoryItemMove.CategoryOf(target); + if (current == null) + { + Logger.WriteEvent("Move did nothing: no category owns the row's object " + field?.ObjectHvo + "."); + return false; + } + var destination = (MoveDestinationChosen ?? ChooseCategoryWithDialog)(current, title); + if (destination == null) + return false; + var focusRows = RowsToFocusAfterDeleting(m_avaloniaEntryForm?.ShownDetail, target); + if (!CategoryItemMove.Move(target, destination, command.UndoText, command.RedoText)) + return false; + if (focusRows.Count > 0) + m_avaloniaEntryForm?.FocusRowOnNextShow(focusRows); + OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); + return true; + } + + private IPartOfSpeech ChooseCategoryWithDialog(IPartOfSpeech current, string title) + => LcmCategoryChooserLauncher.Choose(Cache, m_propertyTable.GetValue
("window"), title); + } +} diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs index 7fc4c2d4e5..cb31567877 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.LexiconMenus.cs @@ -212,20 +212,21 @@ internal LexiconMenuAuthority CreateLexiconMenuAuthority(DetailField field) authority.Answer(MoveRecordDownMessage, leaf => MoveItem(leaf, field, up: false)); authority.Answer(PromoteSubrecordMessage, leaf => RecordNestingItem(leaf, field, demote: false)); authority.Answer(DemoteSubrecordMessage, leaf => RecordNestingItem(leaf, field, demote: true)); + authority.Answer(DuplicateMessage, leaf => DuplicateItem(leaf, field)); + authority.Answer(MoveToCategoryMessage, leaf => MoveToCategoryItem(leaf, field)); return authority; } /// /// The Insert leaves answered natively: a class created in an owning field, or a - /// variant added through the entry's variant back references. Inserts into - /// SubPossibilities stay with the adapter. + /// variant added through the entry's variant back references. /// internal bool InsertLeafInScope(DetailMenuLeaf leaf) { var command = leaf.Command; var fieldName = command.GetParameter("field", null); var className = command.GetParameter("className", null); - if (string.IsNullOrEmpty(fieldName) || string.IsNullOrEmpty(className) || fieldName == "SubPossibilities") + if (string.IsNullOrEmpty(fieldName) || string.IsNullOrEmpty(className)) return false; if (!((IFwMetaDataCacheManaged)Cache.MetaDataCacheAccessor).ClassExists(className)) return false; @@ -362,10 +363,13 @@ internal void OpenGuiControl(DetailField field, Command command) OnAvaloniaDetailEditCompleted(this, EventArgs.Empty); } - // The command's target from the row's object, by its field, className, ownerClass and - // slice parameters; null when none resolves. - private static DetailInsertTarget ResolveInsertTarget(ICmObject rowObject, Command command) - => DetailObjectInsertion.ResolveTarget(rowObject, command.GetParameter("field"), command.GetParameter("className"), + /// + /// The command's target from the row's object in the shown record's view, by its field, + /// className, ownerClass and slice parameters; null when none resolves. + /// + internal DetailInsertTarget ResolveInsertTarget(ICmObject rowObject, Command command) + => DetailObjectInsertion.ResolveTarget(ResolveShownRecord(Clerk?.CurrentObject), rowObject, + command.GetParameter("field"), command.GetParameter("className"), command.GetParameter("ownerClass", null), command.GetParameter("slice", null) == "owner"); /// diff --git a/Src/xWorks/Avalonia/Hosting/RecordEditView.MainMenus.cs b/Src/xWorks/Avalonia/Hosting/RecordEditView.MainMenus.cs index d7aecd37ff..03641f303c 100644 --- a/Src/xWorks/Avalonia/Hosting/RecordEditView.MainMenus.cs +++ b/Src/xWorks/Avalonia/Hosting/RecordEditView.MainMenus.cs @@ -17,9 +17,10 @@ namespace SIL.FieldWorks.XWorks /// /// The Avalonia detail view's answers to commands outside its own menus: View > Show Hidden /// Fields and Show Dictionary Preview in every tool, in Lexicon Edit the Insert menu's and - /// pane bar's inserts and conversions, and in Notebook Edit Find in Dictionary, Add to - /// Dictionary and Demote Record. They act on the current row, the row of the shown record - /// that last had keyboard focus, else on the shown record itself. + /// pane bar's inserts and conversions, in Category Edit the Insert menu's inserts, and in + /// Notebook Edit Find in Dictionary, Add to Dictionary and Demote Record. They act on the + /// current row, the row of the shown record that last had keyboard focus, else on the + /// shown record itself. /// public partial class RecordEditView { @@ -68,9 +69,11 @@ internal DetailCommandAuthority CreateCommandAuthority() authority.Answer(DemoteRecordMessage, DemoteRecordItem); return authority; } - if (CurrentTool != LexiconEditTool) + if (CurrentTool != LexiconEditTool && CurrentTool != CategoryEditTool) return authority; authority.Answer(InsertMessage, MainMenuInsertItem); + if (CurrentTool == CategoryEditTool) + return authority; authority.Answer(InsertMediaFileMessage, leaf => InsertMediaItem(leaf, MainMenuRow())); authority.Answer(InsertPictureMessage, leaf => InsertPictureItem(leaf, MainMenuRow())); authority.Answer(InsertVariantOfShownEntryMessage, InsertVariantOfShownEntryItem); diff --git a/Src/xWorks/DTMenuHandler.cs b/Src/xWorks/DTMenuHandler.cs index 809613f5fa..a9cf7891d6 100644 --- a/Src/xWorks/DTMenuHandler.cs +++ b/Src/xWorks/DTMenuHandler.cs @@ -70,6 +70,9 @@ public class DTMenuHandler: IxCoreColleague, IDisposable /// protected ICmObject m_moveObj; + // The Move command that opened the category list, for its undo text. + private Command m_moveCommand; + /// /// Object just created. /// @@ -515,6 +518,7 @@ public bool OnDataTreeMove(object cmd) { Slice currentSlice = m_dataEntryForm.CurrentSlice; m_moveObj = currentSlice.Object; + m_moveCommand = cmd as Command; if (m_moveObj is IMoInflAffixSlot slot) { ShowPartsOfSpeech(slot.Owner.Hvo); @@ -566,80 +570,13 @@ private void POSPopupTreeManager_AfterSelect(object sender, System.Windows.Forms repo.TryGetObject((e.Node as HvoTreeNode).Hvo, out selectedPOS); if (selectedPOS != null) { - if (m_moveObj is IMoInflAffixSlot slot) - { - MoveSlot(slot, selectedPOS); + CategoryItemMove.Move(m_moveObj, selectedPOS, m_moveCommand?.UndoText ?? xWorksStrings.UndoMoveItem, + m_moveCommand?.RedoText ?? xWorksStrings.RedoMoveItem); + if (m_moveObj is IMoInflAffixSlot) Publisher.Publish(new PublisherParameterObject(EventConstants.MasterRefresh, null, m_propertyTable.GetWindow())); - } - if (m_moveObj is IMoInflAffixTemplate template) - { - MoveTemplate(template, selectedPOS); - } } } - private void MoveSlot(IMoInflAffixSlot slot, IPartOfSpeech selectedPOS) - { - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Undo Move Slot", - "Redo Move Slot", Cache.ActionHandlerAccessor, () => - { - selectedPOS.AffixSlotsOC.Add(slot); - foreach (IMoInflAffMsa msa in slot.Affixes) - { - msa.PartOfSpeechRA = selectedPOS; - } - }); - } - private void MoveTemplate(IMoInflAffixTemplate template, IPartOfSpeech selectedPOS) - { - if (template.Owner == selectedPOS) - { - // Don't move the template to selectedPOS if it is already there. - return; - } - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Undo Move Template", - "Redo Move Template", Cache.ActionHandlerAccessor, () => - { - // Get the template POS before the template is moved. - IPartOfSpeech templatePOS = template.Owner as IPartOfSpeech; - // Move the template. - selectedPOS.AffixTemplatesOS.Add(template); - if (templatePOS.Owner == selectedPOS) - { - // Move template slots up, too. - IList slots = template.PrefixSlotsRS.ToList(); - slots.AddRange(template.SuffixSlotsRS.ToList()); - foreach (IMoInflAffixSlot slot in slots) - { - IPartOfSpeech slotPOS = slot.Owner as IPartOfSpeech; - IPartOfSpeech slotPOSOwner = slotPOS.Owner as IPartOfSpeech; - // Move a slot if it is at the same level as the template - // and moving it couldn't cause a name conflict. - string slotName = slot.Name.BestAnalysisVernacularAlternative.Text; - if (slotPOS == templatePOS && GetPOSSlot(slotPOSOwner, slotName) == null) - MoveSlot(slot, selectedPOS); - } - } - }); - } - - public static IMoInflAffixSlot GetPOSSlot(IPartOfSpeech partOfSpeech, string name) - { - while (partOfSpeech != null) - { - foreach (IMoInflAffixSlot slot in partOfSpeech.AllAffixSlots) - { - // NB: BestAnalysisVernacularAlternative always returns something. - if (slot.Name.BestAnalysisVernacularAlternative.Text == name) - return slot; - } - partOfSpeech = partOfSpeech.Owner as IPartOfSpeech; - } - return null; - } - - - private bool SliceConfiguredForField(XmlNode node, string field) { if (node != null) @@ -686,32 +623,16 @@ protected virtual bool CanInsert(Command command, Slice currentSlice, out int in string fieldName; string cmdClassName; ExtractInsertCommandInfo(command, out fieldName, out cmdClassName); - string rootObjClassName = string.Empty; var rootObj = currentSlice.ContainingDataTree.Root; if (rootObj != null && !rootObj.IsValidObject) return false; - try - { - if (rootObj != null) - rootObjClassName = rootObj.ClassName; - } - catch - { - } - // For a SubPossibilities insertion, the class associated with this field - // in the command node must match the class of the parentObject so that - // we filter out commands to insert an item into the wrong CmPossibility class. - // (If we need to do anything else exceptional for CmPossibilities try subclassing - // DTMenuHandler to handle these things. That requires a menuHandler node in the parameters - // for each of the tools based on a CmPossibility list.) - if (fieldName == "SubPossibilities") + if (!DetailObjectInsertion.OffersSubItem(rootObj, fieldName, cmdClassName)) + return false; + // A sub-item also needs its section among the slices. + if (fieldName == DetailObjectInsertion.SubItemsField + && !currentSlice.ContainingDataTree.HasSubPossibilitiesSlice) { - // we can insert only if we are are inserting into the same class. - if (cmdClassName != rootObjClassName) - return false; - // we can insert only if the ContainingDataTree has a SubPossibilities slice - if(!currentSlice.ContainingDataTree.HasSubPossibilitiesSlice) - return false; + return false; } return Cache != null && DetailObjectInsertion.CanInsert(rootObj, currentSlice.Object, command.Id, fieldName, out index); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs new file mode 100644 index 0000000000..571fd13dc5 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/CategoryEditMenuTests.cs @@ -0,0 +1,376 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; +using XCore; +using static SIL.FieldWorks.XWorks.DetailMenuTestSupport; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The Grammar area's menus in the Avalonia detail view, answered natively through the + /// product : every row menu of a category in Category Edit, + /// Duplicate and Move on an affix slot and an affix template, Insert Subcategory, the + /// Insert menu's commands, and the Subitems menu of Exception "Features". + /// + [TestFixture] + [Apartment(System.Threading.ApartmentState.STA)] + public class CategoryEditMenuTests : XWorksAppTestBase + { + private const string SlotMenuId = "mnuDataTree-POS-AffixSlot"; + private const string TemplateMenuId = "mnuDataTree-POS-AffixTemplate"; + private const string SubcategoriesMenuId = "mnuDataTree-POS-SubPossibilities"; + private const string SubitemsMenuId = "mnuDataTree-SubPossibilities"; + + private PropertyTable m_propertyTable; + private RecordEditView m_view; + private int m_categoryDepth; + private int m_restrictionDepth; + private IPartOfSpeech m_verb; + private IPartOfSpeech m_irregular; + private IPartOfSpeech m_noun; + private IMoInflAffixSlot m_tense; + private IMoInflAffixSlot m_person; + private IMoInflAffixTemplate m_finite; + private IMoInflAffixTemplate m_infinitive; + private ICmPossibility m_restriction; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, m_application.DefaultConfigurationPathname); + // The hidden DataTree the equivalence baseline drives loads the layout Inventory, + // which is keyed by the project path. + Cache.ProjectId.Path = Path.Combine(Path.GetTempPath(), Cache.ProjectId.Name, + Cache.ProjectId.Name + ".junk"); + } + + [SetUp] + public void SetUpWindow() + { + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + TestLocalizationManagerBootstrap.EnsureInitialized(); + TestLocalizationManagerBootstrap.EnsureHelpTopicProvider(m_propertyTable); + LayoutCache.InitializePartInventories(Cache.ProjectId.Name, m_application, Cache.ProjectId.Path); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateData); + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + } + + [TearDown] + public void TearDownWindow() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + foreach (var obj in new ICmObject[] { m_verb, m_noun, m_restriction }) + { + if (obj.IsValidObject) + obj.Delete(); + } + Cache.LangProject.PartsOfSpeechOA.Depth = m_categoryDepth; + Cache.LangProject.MorphologicalDataOA.ProdRestrictOA.Depth = m_restrictionDepth; + }); + m_view = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + { + m_window.Dispose(); + m_window = null; + } + } + + [Test] + public void EveryRowMenu_OfACategoryWithSlotsTemplatesAndSubcategories_IsAnsweredNatively() + { + ShowCategory(m_verb); + var rows = ShownRows(); + foreach (var id in new[] { SlotMenuId, TemplateMenuId, SubcategoriesMenuId }) + Assert.That(rows.Any(r => r.MenuId == id), Is.True, "precondition: a row binds " + id); + + Assert.That(UnansweredMenus(rows), Is.Empty, "no Category Edit menu needs the hidden tree"); + } + + // Contract: the item menus and the Subcategories menu render what the adapter path + // rendered, and are built without the mediator. + [Test] + public void TheSlotTemplateAndSubcategoriesMenus_RenderWhatTheAdapterPathRendered_WithoutTheMediator() + { + ShowCategory(m_verb); + var window = m_propertyTable.GetValue("window"); + foreach (var row in new[] { SlotRow(m_tense), TemplateRow(m_finite), SubcategoriesRow() }) + { + var ids = new[] { row.MenuId }; + var authority = m_view.CreateMenuAuthority(DetailMenuRequest.FromAnchor(null, row, DetailMenuKind.SliceMenu)); + var spy = new MenuDisplaySpyColleague(); + window.Mediator.AddColleague(spy); + string native; + try + { + native = Describe(XCoreMenuBridge.CreateMenuItems(window, ids, null, null, authority)); + } + finally + { + window.Mediator.RemoveColleague(spy); + } + Assert.That(spy.Asked, Is.False, row.MenuId + ": the mediator was asked"); + Assert.That(native, Does.Contain("enabled=True checked=False executes=True"), row.MenuId); + + EnsureAdapter(row.ObjectHvo, row.Field); + var adapter = Describe(XCoreMenuBridge.CreateMenuItems(window, ids, null, null, null)); + + Assert.That(native, Is.EqualTo(adapter), row.MenuId); + } + } + + [Test] + public void DuplicateAffixSlot_CopiesTheSlotIntoItsCategory_InOneUndoableStep() + { + ShowCategory(m_verb); + + Invoke(SlotRow(m_tense), "Duplicate Affix Slot"); + + Assert.That(m_verb.AffixSlotsOC, Has.Count.EqualTo(3)); + Assert.That(m_tense.Name.AnalysisDefaultWritingSystem.Text, Is.EqualTo("Tense"), "the original keeps its name"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_verb.AffixSlotsOC, Is.EquivalentTo(new[] { m_tense, m_person }), "one Undo"); + } + + [Test] + public void DuplicateAffixTemplate_PutsTheCopyRightAfterTheTemplate_InOneUndoableStep() + { + ShowCategory(m_verb); + + Invoke(TemplateRow(m_finite), "Duplicate Affix Template"); + + Assert.That(m_verb.AffixTemplatesOS, Has.Count.EqualTo(3)); + Assert.That(m_verb.AffixTemplatesOS[0], Is.EqualTo(m_finite)); + Assert.That(m_verb.AffixTemplatesOS[2], Is.EqualTo(m_infinitive)); + Assert.That(m_verb.AffixTemplatesOS[1].PrefixSlotsRS.ToList(), Is.EqualTo(new[] { m_tense })); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_verb.AffixTemplatesOS.ToList(), Is.EqualTo(new[] { m_finite, m_infinitive }), "one Undo"); + } + + [Test] + public void MoveAffixSlot_AsksForACategory_AndMovesTheSlotThere_InOneUndoableStep() + { + ShowCategory(m_verb); + m_view.MoveDestinationChosen = (current, title) => + { + Assert.That(current, Is.EqualTo(m_verb), "the category the slot is in"); + Assert.That(title, Is.EqualTo("Move Affix Slot")); + return m_noun; + }; + + Invoke(SlotRow(m_tense), "Move Affix Slot"); + + Assert.That(m_tense.Owner, Is.EqualTo(m_noun)); + Assert.That(ShownRows().Any(r => r.ObjectHvo == m_tense.Hvo), Is.False, "the category no longer shows the slot"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_tense.Owner, Is.EqualTo(m_verb), "one Undo"); + } + + [Test] + public void MoveAffixSlot_Cancelled_OrToTheCategoryItIsIn_ChangesNothing() + { + ShowCategory(m_verb); + var steps = Cache.ActionHandlerAccessor.UndoableSequenceCount; + + m_view.MoveDestinationChosen = (current, title) => null; + Invoke(SlotRow(m_tense), "Move Affix Slot"); + m_view.MoveDestinationChosen = (current, title) => current; + Invoke(SlotRow(m_tense), "Move Affix Slot"); + + Assert.That(m_tense.Owner, Is.EqualTo(m_verb)); + Assert.That(Cache.ActionHandlerAccessor.UndoableSequenceCount, Is.EqualTo(steps), "no undoable step"); + } + + [Test] + public void MoveAffixTemplate_FromASubcategoryToTheCategoryAbove_TakesItsSlotAlong() + { + IMoInflAffixTemplate template = null; + IMoInflAffixSlot slot = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_irregular.AffixSlotsOC.Add(slot = Cache.ServiceLocator.GetInstance().Create()); + slot.Name.SetAnalysisDefaultWritingSystem("Aspect"); + m_irregular.AffixTemplatesOS.Add( + template = Cache.ServiceLocator.GetInstance().Create()); + template.SuffixSlotsRS.Add(slot); + }); + ShowCategory(m_irregular); + m_view.MoveDestinationChosen = (current, title) => + { + Assert.That(title, Is.EqualTo("Move Affix Template")); + return m_verb; + }; + + Invoke(TemplateRow(template), "Move Affix Template"); + + Assert.That(template.Owner, Is.EqualTo(m_verb)); + Assert.That(slot.Owner, Is.EqualTo(m_verb), "the slot it uses follows"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(template.Owner, Is.EqualTo(m_irregular), "one Undo"); + Assert.That(slot.Owner, Is.EqualTo(m_irregular)); + } + + // Not run: a subcategory is created with the modal category catalog dialog. + [Test] + public void InsertSubcategory_GoesUnderTheShownCategory_AndBesideASubcategoryFromItsRow() + { + var command = CommandOf("CmdDataTree-Insert-POS-SubPossibilities"); + + ShowCategory(m_verb); + Assert.That(Item(MenuItems(SubcategoriesRow()), "Insert Subcategory...").IsEnabled, Is.True); + Assert.That(m_view.ResolveInsertTarget(m_verb, command).Owner, Is.EqualTo(m_verb), "a top-level category"); + Assert.That(m_view.ResolveInsertTarget(m_irregular, command).Owner, Is.EqualTo(m_verb), + "from a subcategory's row, beside it"); + + ShowCategory(m_irregular); + Assert.That(Item(MenuItems(SubcategoriesRow()), "Insert Subcategory...").IsEnabled, Is.True); + Assert.That(m_view.ResolveInsertTarget(m_irregular, command).Owner, Is.EqualTo(m_irregular), "a nested category"); + } + + [Test] + public void TheCommandAuthority_InCategoryEdit_AnswersTheInsertMenu_ForTheShownCategory() + { + ShowCategory(m_verb); + Assert.That(m_view.CreateCommandAuthority().AnsweredMessages, Is.EquivalentTo(new[] { RecordEditView.InsertMessage })); + var authority = CommandAuthorities.Of(m_propertyTable); + foreach (var id in new[] + { + "CmdDataTree-Insert-POS-SubPossibilities", "CmdDataTree-Insert-POS-AffixTemplate", + "CmdDataTree-Insert-POS-AffixSlot", "CmdDataTree-Insert-POS-InflectionClass" + }) + { + var display = Display(); + Assert.That(authority.UpdateDisplay(CommandOf(id), display), Is.True, id); + Assert.That(display.Visible && display.Enabled, Is.True, id); + } + + Assert.That(authority.Execute(CommandOf("CmdDataTree-Insert-POS-AffixSlot")), Is.True); + DrainMediatorAndIdleQueues(); + + Assert.That(m_verb.AffixSlotsOC, Has.Count.EqualTo(3), "a new slot in the shown category"); + } + + [Test] + public void ExceptionFeatures_AnswersEveryRowMenuNatively_AndInsertsASubitemInAHierarchicalList() + { + ShowTool("ProdRestrictEdit", m_restriction); + Assert.That(ShownRows().Any(r => r.MenuId == SubitemsMenuId), Is.False, "a flat list has no Subitems row"); + Assert.That(UnansweredMenus(ShownRows()), Is.Empty, "a flat list"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => Cache.LangProject.MorphologicalDataOA.ProdRestrictOA.Depth = 127); + m_view.RefreshDisplay(); + DrainMediatorAndIdleQueues(); + var subitems = ShownRows().First(r => r.MenuId == SubitemsMenuId); + Assert.That(UnansweredMenus(ShownRows()), Is.Empty, "a hierarchical list"); + + Invoke(subitems, "Insert subitem"); + + Assert.That(m_restriction.SubPossibilitiesOS, Has.Count.EqualTo(1)); + } + + // Verb (two slots, two templates, the subcategory Irregular) and Noun in a + // hierarchical category list; one exception feature in its flat list. + private void CreateData() + { + var services = Cache.ServiceLocator; + var list = Cache.LangProject.PartsOfSpeechOA; + m_categoryDepth = list.Depth; + list.Depth = 127; + var categories = services.GetInstance(); + list.PossibilitiesOS.Add(m_verb = categories.Create()); + m_verb.Name.SetAnalysisDefaultWritingSystem("Verb"); + m_verb.SubPossibilitiesOS.Add(m_irregular = categories.Create()); + m_irregular.Name.SetAnalysisDefaultWritingSystem("Irregular"); + list.PossibilitiesOS.Add(m_noun = categories.Create()); + m_noun.Name.SetAnalysisDefaultWritingSystem("Noun"); + var slots = services.GetInstance(); + m_verb.AffixSlotsOC.Add(m_tense = slots.Create()); + m_tense.Name.SetAnalysisDefaultWritingSystem("Tense"); + m_verb.AffixSlotsOC.Add(m_person = slots.Create()); + m_person.Name.SetAnalysisDefaultWritingSystem("Person"); + var templates = services.GetInstance(); + m_verb.AffixTemplatesOS.Add(m_finite = templates.Create()); + m_finite.Name.SetAnalysisDefaultWritingSystem("Finite"); + m_finite.PrefixSlotsRS.Add(m_tense); + m_verb.AffixTemplatesOS.Add(m_infinitive = templates.Create()); + m_infinitive.Name.SetAnalysisDefaultWritingSystem("Infinitive"); + var restrictions = Cache.LangProject.MorphologicalDataOA.ProdRestrictOA; + m_restrictionDepth = restrictions.Depth; + restrictions.Depth = 1; + restrictions.PossibilitiesOS.Add(m_restriction = services.GetInstance().Create()); + m_restriction.Name.SetAnalysisDefaultWritingSystem("irregular plural"); + } + + private void ShowCategory(IPartOfSpeech category) => ShowTool("posEdit", category); + + // Loads the tool's detail view and shows the record in it. + private void ShowTool(string tool, ICmObject record) + { + LoadRecordEditView(m_propertyTable, tool); + DrainMediatorAndIdleQueues(); + m_view = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(m_view, Is.Not.Null, "expected the RecordEditView of " + tool + " to load"); + m_view.Clerk.JumpToRecord(record.Hvo); + DrainMediatorAndIdleQueues(); + Assert.That(m_view.Clerk.CurrentObject, Is.EqualTo(record), "precondition: the view shows the record"); + } + + private DetailField SlotRow(IMoInflAffixSlot slot) + => ShownRows().First(f => f.ObjectHvo == slot.Hvo && f.MenuId == SlotMenuId); + + private DetailField TemplateRow(IMoInflAffixTemplate template) + => ShownRows().First(f => f.ObjectHvo == template.Hvo && f.MenuId == TemplateMenuId); + + private DetailField SubcategoriesRow() => ShownRows().First(f => f.MenuId == SubcategoriesMenuId); + + // The menus of the rows that some id leaves to the hidden tree. + private List UnansweredMenus(IEnumerable rows) + { + var unanswered = new List(); + foreach (var row in rows) + { + foreach (var menu in MenusOf(row)) + { + var authority = m_view.CreateMenuAuthority(DetailMenuRequest.FromAnchor(null, row, menu.Kind)); + if (!XCoreMenuBridge.OwnsAll(authority, menu.Ids)) + unanswered.Add(row.Label + " " + menu.Kind + ": " + string.Join(", ", menu.Ids)); + } + } + return unanswered; + } + + private IReadOnlyList MenuItems(DetailField row) + => DetailMenuTestSupport.MenuItems(m_view, m_propertyTable, row); + + private void Invoke(DetailField row, string label) + { + Run(MenuItems(row), label); + DrainMediatorAndIdleQueues(); + } + + private Command CommandOf(string id) => DetailMenuTestSupport.CommandOf(m_propertyTable, id); + + private void EnsureAdapter(int targetHvo, string fieldName) + => DetailMenuTestSupport.EnsureAdapter(m_view, targetHvo, fieldName); + + private List ShownRows() => DetailMenuTestSupport.ShownRows(m_view); + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DataTreeMoveReachabilityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DataTreeMoveReachabilityTests.cs index 84a10b45ed..7c053e3d10 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DataTreeMoveReachabilityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DataTreeMoveReachabilityTests.cs @@ -11,24 +11,11 @@ namespace SIL.FieldWorks.XWorks { /// - /// DTMenuHandler.OnDataTreeMove (message="DataTreeMove") adds a - /// TreeCombo to CurrentSlice.Control and drops a popup tree on it. On the Avalonia - /// the command target is the HIDDEN, detached, off-screen adapter DataTree, so that combo - /// would never appear and the move could not complete. - /// - /// DECISION: no suppression code is added because the command is NOT REACHABLE from the lexical - /// entry view. message="DataTreeMove" is declared only by the two Grammar-area commands - /// CmdDataTree-Move-POS-AffixSlot and CmdDataTree-Move-POS-AffixTemplate - /// (Configuration/Grammar/DataTreeInclude.xml), and those commands appear only in Grammar - /// menus -- - /// never in the Lexicon configuration the entry view composes its right-click menus from. (The - /// lexicon "Move" commands route through MoveUpObjectInSequence/MoveDownObjectInSequence - /// and the per-field override layer, neither of which is the TreeCombo path.) - /// - /// These tests LOCK that reachability conclusion so a future Lexicon-config change cannot silently - /// introduce the broken-on-Avalonia DataTreeMove command into the entry-view menus without - /// tripping a red test (which would then require routing it through a real chooser or suppressing it - /// on the Avalonia menu). + /// Move (message DataTreeMove) is answered for an affix slot and an affix template only: + /// the Avalonia view asks for the category with a chooser, and WinForms drops a tree on + /// the row. These tests pin that only the two Grammar commands, + /// CmdDataTree-Move-POS-AffixSlot and CmdDataTree-Move-POS-AffixTemplate, + /// send the message, so a new command that sends it fails here until both views answer it. /// [TestFixture] public class DataTreeMoveReachabilityTests @@ -46,19 +33,15 @@ public void DataTreeMoveCommand_IsNotDeclaredInTheLexiconConfiguration() .ToList(); Assert.That(offenders, Is.Empty, - "No Lexicon command may use message=\"DataTreeMove\": that handler (DTMenuHandler.OnDataTreeMove) " - + "adds a TreeCombo to CurrentSlice.Control, which cannot work against Avalonia's " - + "hidden, detached adapter DataTree. If a Move command is genuinely needed here, route it " - + "through a real chooser or suppress it on the Avalonia menu — do not add the TreeCombo path. " - + "Offenders: " + string.Join(", ", offenders)); + "No Lexicon command may send DataTreeMove: both detail views answer it only for an affix " + + "slot or an affix template. Offenders: " + string.Join(", ", offenders)); } [Test] public void DataTreeMoveCommand_IsConfinedToTheGrammarAffixCommands() { - // Sanity-anchor the conclusion: the only DataTreeMove declarations in the shipped configuration - // are the two Grammar affix-slot/template commands. If this set changes, the triage above must - // be revisited (a new consumer might now reach the TreeCombo path). + // The only DataTreeMove declarations in the shipped configuration are the two Grammar + // commands. var configRoot = FwDirectoryFinder.GetCodeSubDirectory(@"Language Explorer\Configuration"); var ids = Directory.GetFiles(configRoot, "*.xml", SearchOption.AllDirectories) .SelectMany(LoadCommands) @@ -71,8 +54,8 @@ public void DataTreeMoveCommand_IsConfinedToTheGrammarAffixCommands() { "CmdDataTree-Move-POS-AffixSlot", "CmdDataTree-Move-POS-AffixTemplate" - }), "DataTreeMove must remain confined to the Grammar affix commands; a new declaration means a " - + "new consumer might reach the unsupported TreeCombo path and the C1 triage must be redone."); + }), "DataTreeMove is answered only for an affix slot or an affix template; a new command that " + + "sends it needs an answer in both detail views."); } private static System.Collections.Generic.IEnumerable LoadCommands(string path) diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailMenuTestSupport.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailMenuTestSupport.cs new file mode 100644 index 0000000000..f02a746247 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailMenuTestSupport.cs @@ -0,0 +1,163 @@ +// 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.Linq; +using System.Reflection; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// What the detail-menu fixtures share: loading a tool's , + /// reading the rows it shows, and building, describing and running a row's menus. + /// + internal static class DetailMenuTestSupport + { + /// Makes the current tool, so the window + /// loads its detail view. + public static void LoadRecordEditView(PropertyTable propertyTable, string toolValue) + { + var windowConfiguration = propertyTable.GetValue("WindowConfiguration"); + Assert.That(windowConfiguration, Is.Not.Null); + var controlNode = windowConfiguration.SelectSingleNode(string.Format( + "//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", + toolValue)); + Assert.That(controlNode, Is.Not.Null, "Expected the RecordEditView configuration node for tool '{0}'.", toolValue); + + propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + propertyTable.SetProperty("currentContentControl", toolValue, true); + propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + /// The rows of the model the host shows now, the instances its menus are + /// asked about. + public static List ShownRows(RecordEditView view) + { + var hostField = typeof(RecordEditView).GetField("m_avaloniaEntryForm", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(hostField, Is.Not.Null, "Missing private field: m_avaloniaEntryForm"); + var host = (DetailHostControl)hostField.GetValue(view); + return host.ShownDetail.Fields.ToList(); + } + + /// The menus a row binds, each with the ids the host builds it from. + public static IEnumerable<(DetailMenuKind Kind, string[] Ids)> MenusOf(DetailField row) + { + var label = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); + if (label.Length > 0) + yield return (DetailMenuKind.SliceMenu, label); + var inString = RecordEditView.ComposeInStringMenuIds(row.ContextMenuId).ToArray(); + if (inString.Length > 0) + yield return (DetailMenuKind.ContextMenu, inString); + if (!string.IsNullOrEmpty(row.HotlinksId)) + yield return (DetailMenuKind.Hotlinks, new[] { row.HotlinksId }); + } + + /// The row's label menu as the host builds it, which no hidden tree may + /// answer. + public static IReadOnlyList MenuItems(RecordEditView view, PropertyTable propertyTable, + DetailField row) + { + var window = propertyTable.GetValue("window"); + var ids = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); + var authority = view.CreateMenuAuthority(DetailMenuRequest.FromAnchor(null, row, DetailMenuKind.SliceMenu)); + Assert.That(XCoreMenuBridge.OwnsAll(authority, ids), Is.True, "the menu is answered natively"); + return XCoreMenuBridge.CreateMenuItems(window, ids, null, null, authority); + } + + /// The item with that label, which the menu must offer. + public static DetailMenuItem Item(IReadOnlyList items, string label) + { + var item = items.FirstOrDefault(i => i.Label == label); + Assert.That(item, Is.Not.Null, "the menu offers '{0}'", label); + return item; + } + + /// Runs the item with that label, which must be enabled. + public static void Run(IReadOnlyList items, string label) + { + var item = Item(items, label); + Assert.That(item.Execute, Is.Not.Null, "'{0}' is enabled", label); + item.Execute(); + } + + public static Command CommandOf(PropertyTable propertyTable, string id) + => (Command)propertyTable.GetValue("window").Mediator.CommandSet[id]; + + public static UIItemDisplayProperties Display() => new UIItemDisplayProperties(null, "item", false, null, true); + + /// One line per item with its state, submenus indented, for comparing two + /// builds of a menu. + public static string Describe(IReadOnlyList items, string indent = "") + { + var lines = new List(); + foreach (var item in items) + { + if (item.IsSeparator) + { + lines.Add(indent + "---"); + continue; + } + lines.Add(string.Format("{0}{1} [enabled={2} checked={3} executes={4}]", indent, item.Label, + item.IsEnabled, item.IsChecked, item.Execute != null)); + if (item.Children.Count > 0) + lines.Add(Describe(item.Children, indent + " ")); + } + return string.Join(Environment.NewLine, lines); + } + + /// Points the hidden tree at a row, for the menu the adapter path + /// renders. + public static void EnsureAdapter(RecordEditView view, int targetHvo, string fieldName) + { + var method = typeof(RecordEditView).GetMethod("EnsureMenuCommandAdapter", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, "EnsureMenuCommandAdapter must exist (adapter targeting seam)"); + method.Invoke(view, new object[] { targetHvo, fieldName }); + } + } + + /// Answers no display query of the detail menus' messages, only records that + /// one arrived. + internal sealed class MenuDisplaySpyColleague : IxCoreColleague + { + public bool Asked { get; private set; } + public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configurationParameters) { } + public IxCoreColleague[] GetMessageTargets() => new IxCoreColleague[] { this }; + public bool ShouldNotCall => false; + public int Priority => (int)ColleaguePriority.High; + + public bool OnDisplayDataTreeDelete(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayShowFieldAlwaysVisible(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayJumpToTool(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplaySwapLexemeWithAllomorph(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayConvertLexemeForm(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeInsert(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayLaunchGuiControl(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayMoveUpObjectInSequence(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayMoveDownObjectInSequence(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayInsertMediaFile(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayPictureProperties(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeMerge(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeDeleteReference(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeAddReference(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeEdit(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplaySwapAllomorphWithLexeme(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayConvertAllomorph(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeCopy(object commandObject, ref UIItemDisplayProperties display) => Record(); + public bool OnDisplayDataTreeMove(object commandObject, ref UIItemDisplayProperties display) => Record(); + + private bool Record() + { + Asked = true; + return false; + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs index 5a542c8d35..5ad614c2bb 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs @@ -19,6 +19,7 @@ using SIL.LCModel.DomainServices; using SIL.LCModel.Infrastructure; using XCore; +using static SIL.FieldWorks.XWorks.DetailMenuTestSupport; using LegacyDataTree = SIL.FieldWorks.Common.Framework.DetailControls.DataTree; namespace SIL.FieldWorks.XWorks @@ -63,11 +64,13 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase "mnuDataTree-Sense", // Lexicon: an id no part reaches (its pronunciation part does not exist). "mnuDataTree-CmMedia", - // Grammar: posEdit rows (two item rows, five section rows: Affix Templates, Affix - // Slots, Inflection Classes, Subclasses, Stem Allomorph Labels), then other tools. + // Grammar: posEdit rows (four item rows, six section rows: Affix Templates, Affix + // Slots, Inflection Classes, Subclasses, Stem Allomorph Labels, Subcategories), then + // other tools. "mnuDataTree-POS-InflectionClass", "mnuDataTree-POS-StemName", + "mnuDataTree-POS-AffixSlot", "mnuDataTree-POS-AffixTemplate", "mnuDataTree-POS-AffixTemplates", "mnuDataTree-POS-AffixSlots", "mnuDataTree-POS-InflectionClasses", - "mnuDataTree-POS-InflectionClass-Subclasses", "mnuDataTree-POS-StemNames", + "mnuDataTree-POS-InflectionClass-Subclasses", "mnuDataTree-POS-StemNames", "mnuDataTree-POS-SubPossibilities", "mnuDataTree-ClosedFeature-Value", "mnuDataTree-ClosedFeature-Values", "mnuDataTree-MoStemName-Region", "mnuDataTree-MoStemName-Regions", "mnuDataTree-Phoneme-Code", "mnuDataTree-Phoneme-Codes", "mnuDataTree-Delete-Adhoc-Allomorph", "mnuDataTree-Delete-Adhoc-Group", @@ -78,7 +81,12 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase // Notebook rows: the Subrecords section's menus and a subrecord's. "mnuDataTree-SubRecords", "mnuDataTree-SubRecords-Hotlinks", "mnuDataTree-Subrecord-Hotlinks", "mnuDataTree-SubRecordSummary", + // A list item's Subitems row: Exception "Features" composes it for a hierarchical + // list. + "mnuDataTree-SubPossibilities", // Lists and Reversal: no Avalonia detail view. + "mnuDataTree-SubSemanticDomain", "mnuDataTree-SubComplexEntryType", "mnuDataTree-SubVariantEntryType", + "mnuDataTree-SubAnthroCategory", "mnuDataTree-SubLocation", "mnuDataTree-DeletePossibility", "mnuDataTree-DeleteQuestion", "mnuDataTree-InsertQuestion", "mnuDataTree-InsertReversalSubentry", "mnuDataTree-InsertReversalSubentry-Hotlinks" }; @@ -157,7 +165,7 @@ public void SetUpWindow() NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateTestEntry); m_propertyTable.SetProperty("UIMode", "New", true); m_propertyTable.SetPropertyPersistence("UIMode", false); - LoadRecordEditView("lexiconEdit"); + LoadRecordEditView(m_propertyTable, "lexiconEdit"); DrainMediatorAndIdleQueues(); m_view = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; Assert.That(m_view, Is.Not.Null, "expected the lexicon edit RecordEditView to load"); @@ -232,6 +240,23 @@ public void Owns_WhenEveryLeafsMessageIsAnswered_AndNotOneMessageShort() "the owned menu builds from the answerers alone"); } + // An answer's scope decides leaf by leaf: a refused leaf leaves its id with the + // adapter and is not built. + [Test] + public void ALeafItsAnswersScopeRefuses_LeavesItsIdUnowned_AndIsNotBuilt() + { + const string menuId = "mnuDataTree-POS-SubPossibilities"; + var accepting = NewAuthority(); + accepting.Answer(RecordEditView.InsertMessage, Echo, leaf => true); + var refusing = NewAuthority(); + refusing.Answer(RecordEditView.InsertMessage, Echo, leaf => false); + + Assert.That(accepting.Owns(menuId), Is.True, "control: the menu's one leaf is in scope"); + Assert.That(refusing.Owns(menuId), Is.False); + Assert.That(() => refusing.Build(menuId, Leaf(menuId, "CmdDataTree-Insert-POS-SubPossibilities")), + Throws.InvalidOperationException, "a leaf outside the scope is not answered"); + } + [Test] public void NeverOwns_AMenuWithoutLeaves_OrWithAListSubmenu_OrWithAPropertyToggle() { @@ -509,10 +534,10 @@ public void InsertLeaf_IsOfferedOnTheGhostRow_DisabledWithoutATarget_AndAbsentWi Assert.That(m_view.InsertItem(subsense, noObject), Is.Null, "no object"); } - // Only the inserts that need the whole tree stay with the adapter, so their ids do - // too: SubPossibilities. + // Every shipped Insert is in scope: a class created in an owning field, a list item's + // sub-item included, or a variant added through the back references. [Test] - public void InsertLeaves_OutsideTheScope_LeaveTheirIdsWithTheAdapter() + public void InsertLeaves_IntoAnOwningFieldOrTheVariantBackReferences_AreInScope() { var authority = m_view.CreateLexiconMenuAuthority(TranslationRow()); @@ -523,11 +548,10 @@ public void InsertLeaves_OutsideTheScope_LeaveTheirIdsWithTheAdapter() Assert.That(m_view.InsertLeafInScope(Leaf("mnuDataTree-VariantForms", "CmdDataTree-Insert-VariantForm")), Is.True, "a virtual back reference: the dialog adds the variant"); Assert.That(m_view.InsertLeafInScope(Leaf("mnuDataTree-POS-SubPossibilities", "CmdDataTree-Insert-POS-SubPossibilities")), - Is.False, "SubPossibilities needs the whole tree"); - Assert.That(authority.Owns("mnuDataTree-POS-SubPossibilities"), Is.False); - Assert.That(() => authority.Build("mnuDataTree-POS-SubPossibilities", - Leaf("mnuDataTree-POS-SubPossibilities", "CmdDataTree-Insert-POS-SubPossibilities")), - Throws.InvalidOperationException, "a leaf outside the scope is not answered"); + Is.True, "a subcategory goes into the category's owning SubPossibilities"); + Assert.That(m_view.InsertLeafInScope(Leaf("mnuDataTree-SubPossibilities", "CmdDataTree-Insert-Possibility")), + Is.True, "a list item's sub-item"); + Assert.That(authority.Owns("mnuDataTree-POS-SubPossibilities"), Is.True); } // The allomorph inserts follow the entry: an affix process only on an affix entry, an @@ -879,7 +903,7 @@ private IReadOnlyList BuildWithSpy(string[] ids, IDetailMenuAuth out bool mediatorAsked) { var window = Window; - var spy = new DisplaySpyColleague(); + var spy = new MenuDisplaySpyColleague(); window.Mediator.AddColleague(spy); try { @@ -893,126 +917,8 @@ private IReadOnlyList BuildWithSpy(string[] ids, IDetailMenuAuth } } - // Answers no display query, only records that one arrived. - private sealed class DisplaySpyColleague : IxCoreColleague - { - public bool Asked { get; private set; } - public void Init(Mediator mediator, PropertyTable propertyTable, XmlNode configurationParameters) { } - public IxCoreColleague[] GetMessageTargets() => new IxCoreColleague[] { this }; - public bool ShouldNotCall => false; - public int Priority => (int)ColleaguePriority.High; - public bool OnDisplayDataTreeDelete(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayShowFieldAlwaysVisible(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayJumpToTool(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplaySwapLexemeWithAllomorph(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayConvertLexemeForm(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayDataTreeInsert(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayLaunchGuiControl(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayMoveUpObjectInSequence(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayMoveDownObjectInSequence(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayInsertMediaFile(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayPictureProperties(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayDataTreeMerge(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayDataTreeDeleteReference(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayDataTreeAddReference(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayDataTreeEdit(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplaySwapAllomorphWithLexeme(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - public bool OnDisplayConvertAllomorph(object commandObject, ref UIItemDisplayProperties display) - { - Asked = true; - return false; - } - } - - private static string Describe(IReadOnlyList items, string indent = "") - { - var lines = new List(); - foreach (var item in items) - { - if (item.IsSeparator) - { - lines.Add(indent + "---"); - continue; - } - lines.Add(string.Format("{0}{1} [enabled={2} checked={3} executes={4}]", indent, item.Label, - item.IsEnabled, item.IsChecked, item.Execute != null)); - if (item.Children.Count > 0) - lines.Add(Describe(item.Children, indent + " ")); - } - return string.Join(Environment.NewLine, lines); - } - private void EnsureAdapter(int targetHvo, string fieldName) - { - var method = typeof(RecordEditView).GetMethod("EnsureMenuCommandAdapter", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.That(method, Is.Not.Null, "EnsureMenuCommandAdapter must exist (adapter targeting seam)"); - method.Invoke(m_view, new object[] { targetHvo, fieldName }); - } + => DetailMenuTestSupport.EnsureAdapter(m_view, targetHvo, fieldName); // A complex form and variant: two allomorphs, a spelling variant, two etymologies, // two pronunciations, an antonym and a whole; its sense has an example, pictures, @@ -1105,20 +1011,6 @@ private ILexReference AddRelation(string typeName, LexRefTypeTags.MappingTypes m return relation; } - private void LoadRecordEditView(string toolValue) - { - var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); - Assert.That(windowConfiguration, Is.Not.Null); - var controlNode = windowConfiguration.SelectSingleNode( - string.Format("//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", toolValue)); - Assert.That(controlNode, Is.Not.Null, "Expected the RecordEditView configuration node for tool '{0}'.", toolValue); - - m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); - m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); - m_propertyTable.SetProperty("currentContentControl", toolValue, true); - m_propertyTable.SetPropertyPersistence("currentContentControl", false); - } - private static object GetField(object target, string fieldName) { var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/NotebookMenuTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/NotebookMenuTests.cs index 5ab1e56b39..62fe4b9af1 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/NotebookMenuTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/NotebookMenuTests.cs @@ -6,11 +6,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; -using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; -using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.Seams; using SIL.FieldWorks.Common.FwUtils; @@ -18,6 +15,7 @@ using SIL.LCModel.Core.Text; using SIL.LCModel.Infrastructure; using XCore; +using static SIL.FieldWorks.XWorks.DetailMenuTestSupport; namespace SIL.FieldWorks.XWorks { @@ -67,7 +65,7 @@ public void SetUpWindow() NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateRecords); m_propertyTable.SetProperty("UIMode", "New", true); m_propertyTable.SetPropertyPersistence("UIMode", false); - LoadRecordEditView("notebookEdit"); + LoadRecordEditView(m_propertyTable, "notebookEdit"); DrainMediatorAndIdleQueues(); m_view = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; Assert.That(m_view, Is.Not.Null, "expected the notebook edit RecordEditView to load"); @@ -387,40 +385,12 @@ private DetailField TextRow() private DetailField SubrecordRow(IRnGenericRec subrecord) => ShownRows().First(f => f.ObjectHvo == subrecord.Hvo && f.MenuId == SubrecordMenuId); - private static IEnumerable<(DetailMenuKind Kind, string[] Ids)> MenusOf(DetailField row) - { - var label = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); - if (label.Length > 0) - yield return (DetailMenuKind.SliceMenu, label); - var inString = RecordEditView.ComposeInStringMenuIds(row.ContextMenuId).ToArray(); - if (inString.Length > 0) - yield return (DetailMenuKind.ContextMenu, inString); - if (!string.IsNullOrEmpty(row.HotlinksId)) - yield return (DetailMenuKind.Hotlinks, new[] { row.HotlinksId }); - } - - // The row's label menu as the host builds it, which no hidden tree may answer. private IReadOnlyList MenuItems(DetailField row) - { - var window = m_propertyTable.GetValue("window"); - var ids = RecordEditView.ComposeSliceMenuIds(row.MenuId, row.IsMultiStringRow).ToArray(); - var authority = m_view.CreateMenuAuthority(DetailMenuRequest.FromAnchor(null, row, DetailMenuKind.SliceMenu)); - Assert.That(XCoreMenuBridge.OwnsAll(authority, ids), Is.True, "the menu is answered natively"); - return XCoreMenuBridge.CreateMenuItems(window, ids, null, null, authority); - } - - private static DetailMenuItem Item(IReadOnlyList items, string label) - { - var item = items.FirstOrDefault(i => i.Label == label); - Assert.That(item, Is.Not.Null, "the menu offers '{0}'", label); - return item; - } + => DetailMenuTestSupport.MenuItems(m_view, m_propertyTable, row); private void Invoke(DetailField row, string label) { - var item = Item(MenuItems(row), label); - Assert.That(item.Execute, Is.Not.Null, "'{0}' is enabled", label); - item.Execute(); + Run(MenuItems(row), label); DrainMediatorAndIdleQueues(); } @@ -428,33 +398,8 @@ private DetailMenuLeaf TextMenuLeaf(string message) => XCoreMenuBridge.ResolveMenu(m_propertyTable.GetValue("window"), StructuredTextMenuAuthority.MenuId) .Leaves.Single(l => l.Message == message); - private Command CommandOf(string id) - => (Command)m_propertyTable.GetValue("window").Mediator.CommandSet[id]; - - private static UIItemDisplayProperties Display() => new UIItemDisplayProperties(null, "item", false, null, true); + private Command CommandOf(string id) => DetailMenuTestSupport.CommandOf(m_propertyTable, id); - // The rows of the model the host shows now, the instances its menus are asked about. - private List ShownRows() - { - var hostField = typeof(RecordEditView).GetField("m_avaloniaEntryForm", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.That(hostField, Is.Not.Null, "Missing private field: m_avaloniaEntryForm"); - var host = (DetailHostControl)hostField.GetValue(m_view); - return host.ShownDetail.Fields.ToList(); - } - - private void LoadRecordEditView(string toolValue) - { - var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); - Assert.That(windowConfiguration, Is.Not.Null); - var controlNode = windowConfiguration.SelectSingleNode(string.Format( - "//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", - toolValue)); - Assert.That(controlNode, Is.Not.Null, "Expected the RecordEditView configuration node for tool '{0}'.", toolValue); - - m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); - m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); - m_propertyTable.SetProperty("currentContentControl", toolValue, true); - m_propertyTable.SetPropertyPersistence("currentContentControl", false); - } + private List ShownRows() => DetailMenuTestSupport.ShownRows(m_view); } }