diff --git a/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs new file mode 100644 index 000000000..4e3c31639 --- /dev/null +++ b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorMultiSelectTests.cs @@ -0,0 +1,305 @@ +using Editors.AnimationMeta.Presentation; +using Shared.Core.Events.Global; +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 + 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 + 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_SingleSelectedTag_RemovesTag() + { + // 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: Select a single tag using IsSelected + var tagToDelete = editor.Tags.Last(); + tagToDelete.IsSelected = true; + + editor.DeleteActionCommand.Execute(null); + + // Assert + Assert.That(editor.Tags.Count, Is.EqualTo(initialCount - 1)); + Assert.That(editor.Tags, Does.Not.Contain(tagToDelete)); + } + + [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; + + // Act + 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 + 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 + 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_SingleSelectedTag_MovesTag() + { + // 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: Select a single tag using IsSelected + editor.Tags[4].IsSelected = true; + var originalType = editor.Tags[4]._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 + editor.Tags[0].IsSelected = true; + var originalType = editor.Tags[0]._input.GetType(); + + editor.MoveUpActionCommand.Execute(null); + + // Assert: Tag should remain at position 0 + 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 + editor.Tags[^1].IsSelected = true; + var originalType = editor.Tags[^1]._input.GetType(); + + editor.MoveDownActionCommand.Execute(null); + + // Assert: Tag should remain at the last position + 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; + + // Act + 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)); + } + } +} diff --git a/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorViewModelTests.cs b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorViewModelTests.cs index ed74f5b42..a56dbbcd7 100644 --- a/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorViewModelTests.cs +++ b/Editors/AnimationMeta/Test.AnimationMeta/MetaDataEditorViewModelTests.cs @@ -105,7 +105,7 @@ public void MetaDataEditor_DeleteAndSave() Assert.That(initialCount, Is.GreaterThan(0)); // Select last tag and delete - editor.SelectedTag = editor.Tags.Last(); + editor.Tags.Last().IsSelected = true; editor.DeleteActionCommand.Execute(null); Assert.That(editor.Tags.Count, Is.EqualTo(initialCount - 1)); @@ -140,7 +140,7 @@ public void MetaDataEditor_MoveUpAndSave() Assert.That(editor.ParsedFile, Is.Not.Null); // Move second entry up - editor.SelectedTag = editor.Tags[4]; + editor.Tags[4].IsSelected = true; editor.MoveUpActionCommand.Execute(null); editor.SaveActionCommand.Execute(null); diff --git a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/DeleteEntryCommand.cs index 269acbaf4..78b1fc620 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 Editors.AnimationMeta.Presentation; using Shared.Core.Events; +using Shared.GameFormats.AnimationMeta.Parsing; namespace Editors.AnimationMeta.MetaEditor.Commands { @@ -7,14 +8,23 @@ internal class DeleteEntryCommand : IUiCommand { public void Execute(MetaDataEditorViewModel controller) { - var itemToRemove = controller.SelectedAttribute; - if (itemToRemove == null || controller.ParsedFile == null) + if (controller?.ParsedFile == null) return; - controller.ParsedFile.Attributes.Remove(itemToRemove); + var itemsToRemove = controller.Tags + .Where(x => x.IsSelected) + .Select(x => x._input) + .ToList(); + + if (itemsToRemove.Count == 0) + return; + + foreach (var item in itemsToRemove) + { + controller.ParsedFile.Attributes.Remove(item); + } + controller.UpdateView(); - controller.SelectedTag = controller.Tags.FirstOrDefault(); } - } } diff --git a/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs b/Editors/MetaDataEditor/AnimationMeta/MetaEditor/Commands/MoveEntryCommand.cs index d111d8223..57aac96a5 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 Editors.AnimationMeta.Presentation; using Shared.Core.Events; +using Shared.GameFormats.AnimationMeta.Parsing; namespace Editors.AnimationMeta.MetaEditor.Commands { @@ -7,39 +8,107 @@ internal class MoveEntryCommand : IUiCommand { public void ExecuteUp(MetaDataEditorViewModel controller) { - var itemToMove = controller.SelectedAttribute; - if (itemToMove == null || controller.ParsedFile == null) + if (controller?.ParsedFile == null) return; - var currentIndex = controller.ParsedFile.Attributes.IndexOf(itemToMove); - if (currentIndex == 0) - return; + var itemsToMove = controller.Tags + .Where(x => x.IsSelected) + .Select(x => x._input) + .ToList(); - 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 + var sortedItems = itemsToMove + .OrderBy(x => attributes.IndexOf(x)) + .ToList(); + + bool moved = false; + + foreach (var item in sortedItems) + { + var currentIndex = attributes.IndexOf(item); + + if (currentIndex <= 0) + continue; + + var itemAbove = attributes[currentIndex - 1]; + + // If the item above is also selected, skip to keep the block together + if (itemsToMove.Contains(itemAbove)) + continue; + + attributes.RemoveAt(currentIndex); + attributes.Insert(currentIndex - 1, item); + moved = true; + } + + if (moved) + { + controller.UpdateView(); + RestoreSelection(controller, itemsToMove); + } } public void ExecuteDown(MetaDataEditorViewModel controller) { - var itemToMove = controller.SelectedAttribute; - if (itemToMove == null || controller.ParsedFile == null) + if (controller?.ParsedFile == null) return; - var currentIndex = controller.ParsedFile.Attributes.IndexOf(itemToMove); - if (currentIndex == controller.ParsedFile.Attributes.Count -1) + var itemsToMove = controller.Tags + .Where(x => x.IsSelected) + .Select(x => x._input) + .ToList(); + + 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 + var sortedItems = itemsToMove + .OrderByDescending(x => attributes.IndexOf(x)) + .ToList(); + + bool moved = false; + + foreach (var item in sortedItems) + { + var currentIndex = attributes.IndexOf(item); + + if (currentIndex < 0 || currentIndex >= attributes.Count - 1) + continue; + + var itemBelow = attributes[currentIndex + 1]; + + // If the item below is also selected, skip to keep the block together + if (itemsToMove.Contains(itemBelow)) + continue; + + attributes.RemoveAt(currentIndex); + attributes.Insert(currentIndex + 1, item); + moved = true; + } + + if (moved) + { + controller.UpdateView(); + RestoreSelection(controller, itemsToMove); + } + } + + private void RestoreSelection(MetaDataEditorViewModel controller, List movedItems) + { + foreach (var tag in controller.Tags) + { + if (movedItems.Contains(tag._input)) + { + tag.IsSelected = true; + } + } } } }