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); } }