diff --git a/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs new file mode 100644 index 000000000..5d15bba4c --- /dev/null +++ b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs @@ -0,0 +1,330 @@ +using Editors.AnimationMeta.Presentation; +using Shared.Core.Events.Global; +using Shared.GameFormats.AnimationMeta.Definitions; +using Shared.GameFormats.AnimationMeta.Parsing; +using Test.TestingUtility.Shared; +using Test.TestingUtility.TestUtility; + +namespace Test.AnimationMeta +{ + [TestFixture] + public class MetaDataEditorMultiSelectTests + { + [Test] + public void DeleteAction_MultiSelectedTags_RemovesAllSelectedTags() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + var outputPackFile = runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + var initialCount = editor.Tags.Count; + Assert.That(initialCount, Is.GreaterThan(2)); + + // Act: Select multiple tags using IsSelected flag (multi-selection mode) + var tag1 = editor.Tags[0]; + var tag2 = editor.Tags[1]; + tag1.IsSelected = true; + tag2.IsSelected = true; + + editor.DeleteActionCommand.Execute(null); + + // Assert: Both selected tags should be removed + Assert.That(editor.Tags.Count, Is.EqualTo(initialCount - 2)); + Assert.That(editor.Tags, Does.Not.Contain(tag1)); + Assert.That(editor.Tags, Does.Not.Contain(tag2)); + + // Verify persistence by saving and reloading + editor.SaveActionCommand.Execute(null); + var savedFile = runner.PackFileService.FindFile(filePath, outputPackFile); + Assert.That(savedFile, Is.Not.Null); + + var parser = runner.GetRequiredServiceInCurrentEditorScope(); + var parsedFile = parser.ParseFile(savedFile); + Assert.That(parsedFile.Attributes.Count, Is.EqualTo(initialCount - 2)); + } + + [Test] + public void DeleteAction_SelectedTagOnly_RemovesSingleTag() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + var outputPackFile = runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + var initialCount = editor.Tags.Count; + + // Act: Use SelectedTag instead of IsSelected (single-selection mode) + // This verifies backward compatibility with existing behavior + editor.SelectedTag = editor.Tags.Last(); + var tagToDelete = editor.SelectedTag; + + editor.DeleteActionCommand.Execute(null); + + // Assert: Only the selected tag should be removed + Assert.That(editor.Tags.Count, Is.EqualTo(initialCount - 1)); + Assert.That(editor.Tags, Does.Not.Contain(tagToDelete)); + + // Verify default selection behavior + Assert.That(editor.SelectedTag, Is.Not.Null); + Assert.That(editor.SelectedTag, Is.EqualTo(editor.Tags[0])); + } + + [Test] + public void DeleteAction_NoSelection_DoesNothing() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + var initialCount = editor.Tags.Count; + + // Clear all selection states + foreach (var tag in editor.Tags) + tag.IsSelected = false; + editor.SelectedTag = null; + + // Act: Delete with no selection + editor.DeleteActionCommand.Execute(null); + + // Assert: No tags should be deleted + Assert.That(editor.Tags.Count, Is.EqualTo(initialCount)); + } + + [Test] + public void MoveUpAction_MultiSelectedTags_MovesAllSelectedAsBlock() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + Assert.That(editor.Tags.Count, Is.GreaterThan(3)); + + // Select consecutive tags to test block movement + var tag1 = editor.Tags[2]; + var tag2 = editor.Tags[3]; + tag1.IsSelected = true; + tag2.IsSelected = true; + + var type1 = tag1._input.GetType(); + var type2 = tag2._input.GetType(); + + // Act: Move selected block up + editor.MoveUpActionCommand.Execute(null); + + // Assert: Both tags should move up together, maintaining their order + Assert.That(editor.Tags[1]._input, Is.InstanceOf(type1)); + Assert.That(editor.Tags[2]._input, Is.InstanceOf(type2)); + Assert.That(editor.Tags[1].IsSelected, Is.True); + Assert.That(editor.Tags[2].IsSelected, Is.True); + } + + [Test] + public void MoveDownAction_MultiSelectedTags_MovesAllSelectedAsBlock() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + Assert.That(editor.Tags.Count, Is.GreaterThan(3)); + + // Select consecutive tags to test block movement + var tag1 = editor.Tags[1]; + var tag2 = editor.Tags[2]; + tag1.IsSelected = true; + tag2.IsSelected = true; + + var type1 = tag1._input.GetType(); + var type2 = tag2._input.GetType(); + + // Act: Move selected block down + editor.MoveDownActionCommand.Execute(null); + + // Assert: Both tags should move down together, maintaining their order + Assert.That(editor.Tags[2]._input, Is.InstanceOf(type1)); + Assert.That(editor.Tags[3]._input, Is.InstanceOf(type2)); + Assert.That(editor.Tags[2].IsSelected, Is.True); + Assert.That(editor.Tags[3].IsSelected, Is.True); + } + + [Test] + public void MoveUpAction_SelectedTagOnly_MovesSingleTag() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + var outputPackFile = runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + + // Act: Use SelectedTag instead of IsSelected (single-selection mode) + // This verifies backward compatibility with existing tests + editor.SelectedTag = editor.Tags[4]; + var originalType = editor.SelectedTag._input.GetType(); + + editor.MoveUpActionCommand.Execute(null); + + // Assert: Tag should move up one position + Assert.That(editor.Tags[3]._input, Is.InstanceOf(originalType)); + + // Verify persistence + editor.SaveActionCommand.Execute(null); + var savedFile = runner.PackFileService.FindFile(filePath, outputPackFile); + var parser = runner.GetRequiredServiceInCurrentEditorScope(); + var parsedFile = parser.ParseFile(savedFile); + Assert.That(parsedFile.Attributes[3], Is.InstanceOf(originalType)); + } + + [Test] + public void MoveUpAction_TopTag_DoesNothing() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + + // Act: Try to move the topmost tag up + editor.SelectedTag = editor.Tags[0]; + var originalType = editor.Tags[0]._input.GetType(); + + editor.MoveUpActionCommand.Execute(null); + + // Assert: Tag should remain at position 0 (boundary check) + Assert.That(editor.Tags[0]._input, Is.InstanceOf(originalType)); + } + + [Test] + public void MoveDownAction_BottomTag_DoesNothing() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + + // Act: Try to move the bottommost tag down + editor.SelectedTag = editor.Tags[^1]; + var originalType = editor.Tags[^1]._input.GetType(); + + editor.MoveDownActionCommand.Execute(null); + + // Assert: Tag should remain at the last position (boundary check) + Assert.That(editor.Tags[^1]._input, Is.InstanceOf(originalType)); + } + + [Test] + public void MoveDownAction_NoSelection_DoesNothing() + { + // Arrange + var packFile = PathHelper.GetDataFile("Throt.pack"); + var runner = new AssetEditorTestRunner(); + runner.CreateCaContainer(); + runner.LoadPackFile(packFile, true); + + var filePath = @"animations/battle/humanoid17/throt_whip_catcher/attacks/hu17_whip_catcher_attack_05.anm.meta"; + var metaPackFile = runner.PackFileService.FindFile(filePath); + var editor = runner.CommandFactory + .Create() + .Execute(metaPackFile!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + Assert.That(editor.ParsedFile, Is.Not.Null); + + var initialTypes = editor.Tags.Select(t => t._input.GetType()).ToList(); + + // Clear all selection states + foreach (var tag in editor.Tags) + tag.IsSelected = false; + editor.SelectedTag = null; + + // Act: Move with no selection + editor.MoveDownActionCommand.Execute(null); + + // Assert: Order should remain unchanged + var currentTypes = editor.Tags.Select(t => t._input.GetType()).ToList(); + Assert.That(currentTypes, Is.EqualTo(initialTypes)); + } + // ❌ DELETE THIS ENTIRE METHOD + //[Test] + //public void DeleteAndMoveOperations_WithNullParsedFile_DoesNotCrash() + //{ + // Arrange: Create editor without loading a file (ParsedFile will be null) + //var runner = new AssetEditorTestRunner(); + //runner.CreateCaContainer(); + + //var editor = runner.CommandFactory + //.Create() + //.Execute(null!, Shared.Core.ToolCreation.EditorEnums.Meta_Editor); + + // Act & Assert: All operations should handle null gracefully without throwing + //Assert.DoesNotThrow(() => editor.DeleteActionCommand.Execute(null)); + //Assert.DoesNotThrow(() => editor.MoveUpActionCommand.Execute(null)); + //Assert.DoesNotThrow(() => editor.MoveDownActionCommand.Execute(null)); + } +} diff --git a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs index 269acbaf4..b5227164c 100644 --- a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs +++ b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs @@ -1,5 +1,6 @@ using Editors.AnimationMeta.Presentation; using Shared.Core.Events; +using Shared.GameFormats.AnimationMeta.Parsing; namespace Editors.AnimationMeta.MetaEditor.Commands { @@ -7,14 +8,62 @@ internal class DeleteEntryCommand : IUiCommand { public void Execute(MetaDataEditorViewModel controller) { - var itemToRemove = controller.SelectedAttribute; - if (itemToRemove == null || controller.ParsedFile == null) + // Validate controller state + if (controller?.ParsedFile == null) return; - controller.ParsedFile.Attributes.Remove(itemToRemove); + // Support both multi-selection (IsSelected) and single selection (SelectedTag) + // This ensures compatibility with different UI interaction patterns + var itemsToRemove = GetSelectedItems(controller); + + if (itemsToRemove.Count == 0) + return; + + // Batch remove selected items from the underlying data structure + // Using ToList() to avoid collection modification during enumeration + foreach (var item in itemsToRemove) + { + controller.ParsedFile.Attributes.Remove(item); + } + + // Refresh the view to reflect changes controller.UpdateView(); - controller.SelectedTag = controller.Tags.FirstOrDefault(); + + // Set default selection to the first available item + // This prevents UI from being in an undefined state + if (controller.Tags.Count > 0) + { + controller.SelectedTag = controller.Tags[0]; + } + else + { + controller.SelectedTag = null; + } } + /// + /// Retrieves selected items supporting both multi-selection and single-selection modes. + /// Priority: IsSelected (multi) > SelectedTag (single) + /// + private List GetSelectedItems(MetaDataEditorViewModel controller) + { + // First, check for multi-selection via IsSelected flag + var multiSelected = controller.Tags + .Where(x => x.IsSelected) + .Select(x => x._input) + .ToList(); + + if (multiSelected.Count > 0) + return multiSelected; + + // Fallback to single selection via SelectedTag + // This ensures backward compatibility with existing tests and single-select scenarios + if (controller.SelectedTag != null) + { + return new List { controller.SelectedTag._input }; + } + + return new List(); + } } } diff --git a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs index d111d8223..5bdfa46af 100644 --- a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs +++ b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs @@ -1,5 +1,6 @@ using Editors.AnimationMeta.Presentation; using Shared.Core.Events; +using Shared.GameFormats.AnimationMeta.Parsing; namespace Editors.AnimationMeta.MetaEditor.Commands { @@ -7,39 +8,147 @@ internal class MoveEntryCommand : IUiCommand { public void ExecuteUp(MetaDataEditorViewModel controller) { - var itemToMove = controller.SelectedAttribute; - if (itemToMove == null || controller.ParsedFile == null) + // Validate controller state + if (controller?.ParsedFile == null) return; - var currentIndex = controller.ParsedFile.Attributes.IndexOf(itemToMove); - if (currentIndex == 0) - return; + var itemsToMove = GetSelectedItems(controller); - controller.ParsedFile.Attributes.Remove(itemToMove); - controller.ParsedFile.Attributes.Insert(currentIndex - 1, itemToMove); - controller.UpdateView(); - controller.SelectedTag = controller.Tags - .Where(x => x._input == itemToMove) - .FirstOrDefault(); + if (itemsToMove.Count == 0) + return; + + var attributes = controller.ParsedFile.Attributes; + + // Sort ascending: move top items first to maintain relative order + // This prevents items from jumping over each other during batch move + var sortedItems = itemsToMove + .OrderBy(x => attributes.IndexOf(x)) + .ToList(); + + bool moved = false; + + foreach (var item in sortedItems) + { + var currentIndex = attributes.IndexOf(item); + + // Boundary check: can't move up if already at top + if (currentIndex <= 0) + continue; + + var itemAbove = attributes[currentIndex - 1]; + + // If the item above is also selected, skip to maintain selection block + // This keeps multi-selected items together as a group + if (itemsToMove.Contains(itemAbove)) + continue; + + // Perform the move operation + attributes.RemoveAt(currentIndex); + attributes.Insert(currentIndex - 1, item); + moved = true; + } + + if (moved) + { + // Refresh view and restore selection state + controller.UpdateView(); + RestoreSelection(controller, itemsToMove); + } } public void ExecuteDown(MetaDataEditorViewModel controller) { - var itemToMove = controller.SelectedAttribute; - if (itemToMove == null || controller.ParsedFile == null) + // Validate controller state + if (controller?.ParsedFile == null) return; - var currentIndex = controller.ParsedFile.Attributes.IndexOf(itemToMove); - if (currentIndex == controller.ParsedFile.Attributes.Count -1) + var itemsToMove = GetSelectedItems(controller); + + if (itemsToMove.Count == 0) return; - controller.ParsedFile.Attributes.Remove(itemToMove); - controller.ParsedFile.Attributes.Insert(currentIndex + 1, itemToMove); - controller.UpdateView(); - controller.SelectedTag = controller.Tags - .Where(x => x._input == itemToMove) - .FirstOrDefault(); + var attributes = controller.ParsedFile.Attributes; + + // Sort descending: move bottom items first to maintain relative order + // This prevents items from jumping over each other during batch move + var sortedItems = itemsToMove + .OrderByDescending(x => attributes.IndexOf(x)) + .ToList(); + + bool moved = false; + + foreach (var item in sortedItems) + { + var currentIndex = attributes.IndexOf(item); + + // Boundary check: can't move down if already at bottom + if (currentIndex < 0 || currentIndex >= attributes.Count - 1) + continue; + + var itemBelow = attributes[currentIndex + 1]; + + // If the item below is also selected, skip to maintain selection block + // This keeps multi-selected items together as a group + if (itemsToMove.Contains(itemBelow)) + continue; + + // Perform the move operation + attributes.RemoveAt(currentIndex); + attributes.Insert(currentIndex + 1, item); + moved = true; + } + + if (moved) + { + // Refresh view and restore selection state + controller.UpdateView(); + RestoreSelection(controller, itemsToMove); + } + } + + /// + /// Retrieves selected items supporting both multi-selection and single-selection modes. + /// Priority: IsSelected (multi) > SelectedTag (single) + /// + private List GetSelectedItems(MetaDataEditorViewModel controller) + { + // First, check for multi-selection via IsSelected flag + var multiSelected = controller.Tags + .Where(x => x.IsSelected) + .Select(x => x._input) + .ToList(); + + if (multiSelected.Count > 0) + return multiSelected; + + // Fallback to single selection via SelectedTag + // This ensures backward compatibility with existing tests and single-select scenarios + if (controller.SelectedTag != null) + { + return new List { controller.SelectedTag._input }; + } + + return new List(); + } + + /// + /// Restores the selection state after a move operation. + /// Sets IsSelected = true for all moved items and updates SelectedTag. + /// + private void RestoreSelection(MetaDataEditorViewModel controller, List movedItems) + { + // Restore IsSelected state for all moved items + foreach (var tag in controller.Tags) + { + if (movedItems.Contains(tag._input)) + { + tag.IsSelected = true; + } + } + // Update SelectedTag to the first selected item + // This maintains consistency with UI expectations + controller.SelectedTag = controller.Tags.FirstOrDefault(x => x.IsSelected); } } }