From ead4da615a33198d7eb5dfcb6a39f7bc9c7894f7 Mon Sep 17 00:00:00 2001 From: Zeljko Predjeskovic Date: Wed, 8 Jul 2026 14:56:35 +0200 Subject: [PATCH] #13460 - image edit button unresponsive after saving image edit changes #13459 - image edit - pin size is not saved --- .../Dialog/IUserInteraction.cs | 2 +- .../Dialog/UserInteractionService.cs | 4 +- .../Views/ActionSheetDialogViewModel.cs | 12 +- .../Services/MarkerOptionsInputService.cs | 4 +- .../Services/PinInputService.cs | 8 +- .../StrokeStyleOptionsInputService.cs | 4 +- .../Svg.Editor.Avalon.Forms.csproj | 4 +- .../Svg.Editor.Avalon.Views.csproj | 4 +- .../ActionSheetDialogResultViewModelTests.cs | 242 ++++++++++++++++ .../MarkerOptionsInputServiceTests.cs | 42 ++- Svg.Editor.Core.Tests/PinInputServiceTests.cs | 205 +++++++++++++ Svg.Editor.Core.Tests/PinToolTests.cs | 271 ++++++++++++++++++ .../StrokeStyleOptionsInputServiceTests.cs | 28 +- .../Localization/Resources/String.de.resx | 3 + .../Localization/Resources/String.fr.resx | 3 + .../Localization/Resources/String.resx | 3 + Svg.Editor.Core/Svg.Editor.Core.csproj | 4 +- Svg.Tests.Win/SvgDocumentTest.cs | 135 +++++++++ Svg/Extensions.cs | 40 ++- Svg/Svg.csproj | 4 +- 20 files changed, 959 insertions(+), 63 deletions(-) create mode 100644 Svg.Editor.Core.Tests/Forms/ActionSheetDialogResultViewModelTests.cs create mode 100644 Svg.Editor.Core.Tests/PinInputServiceTests.cs create mode 100644 Svg.Editor.Core.Tests/PinToolTests.cs diff --git a/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs b/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs index 315143830..e4e827f7b 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/IUserInteraction.cs @@ -36,7 +36,7 @@ Task ConfirmThreeButtonsAsync( string neutral = "Cancel", bool cancellable = true); - Task ActionSheetAsync(string message, IEnumerable options, string? title = null, string cancelButton = "Cancel", CancellationToken? cancelToken = null, bool cancellable = false); + Task ActionSheetAsync(string message, IEnumerable options, string? title = null, string cancelButton = "Cancel", CancellationToken? cancelToken = null, bool cancellable = false, int selectedIndex = -1); /* Ignored deliberately by Alex: Do we really need these "input dialogs" anymore now that Avalonia Date/Time picker _work_? Task PromptDateAsync(DateTime? selectedDate); diff --git a/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs b/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs index 3de8f1b38..479ad3d1f 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/UserInteractionService.cs @@ -190,7 +190,7 @@ public Task ColorPickerAsync(string? title = null, string okButton = "OK" }); } - public Task ActionSheetAsync(string message, IEnumerable options, string? title = null, string cancelButton = "Cancel", CancellationToken? cancelToken = null, bool cancellable = false) + public Task ActionSheetAsync(string message, IEnumerable options, string? title = null, string cancelButton = "Cancel", CancellationToken? cancelToken = null, bool cancellable = false, int selectedIndex = -1) { return Dispatcher.UIThread.InvokeAsync(async () => { @@ -204,7 +204,7 @@ public Task ActionSheetAsync(string message, IEnumerable options PrimaryButtonText = "", CloseButtonText = cancellable ? cancelButton : null, }; - var vm = new ActionSheetDialogResultViewModel(options); + var vm = new ActionSheetDialogResultViewModel(options, selectedIndex); vm.Initialize(d); vm.CanCancel = cancellable; d.Content = new ActionSheetDialogContent() { DataContext = vm }; diff --git a/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs b/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs index e201ca518..fef214090 100644 --- a/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs +++ b/Svg.Editor.Avalonia.Forms/Dialog/Views/ActionSheetDialogViewModel.cs @@ -15,10 +15,10 @@ public ActionSheetItem? SelectedItem set => SetField(ref _selectedItem, value); } - public ActionSheetDialogResultViewModel(IEnumerable items) + public ActionSheetDialogResultViewModel(IEnumerable items, int selectedIndex = -1) { Items = new ObservableCollection(items.Select(i => new ActionSheetItem(i, i))); - SelectedItem = Items.FirstOrDefault(); + SelectedItem = GetInitialSelection(selectedIndex); } public ActionSheetDialogResultViewModel(IEnumerable> items) @@ -27,6 +27,14 @@ public ActionSheetDialogResultViewModel(IEnumerable= 0 && selectedIndex < Items.Count) + return Items[selectedIndex]; + + return Items.FirstOrDefault(); + } + public ObservableCollection Items { get; } // Can only close if diff --git a/Svg.Editor.Avalonia.Forms/Services/MarkerOptionsInputService.cs b/Svg.Editor.Avalonia.Forms/Services/MarkerOptionsInputService.cs index f5f3d1838..2ca32b8c1 100644 --- a/Svg.Editor.Avalonia.Forms/Services/MarkerOptionsInputService.cs +++ b/Svg.Editor.Avalonia.Forms/Services/MarkerOptionsInputService.cs @@ -24,7 +24,7 @@ public async Task GetUserInput(IEnumerable markerStartOptions, in { var mso = markerStartOptions.ToList(); - var start = await _userInteractionService.ActionSheetAsync(_localizationService.GetString("Svg.Editor.Marker.Options.Start"), mso.ToArray(), cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true); + var start = await _userInteractionService.ActionSheetAsync(_localizationService.GetString("Svg.Editor.Marker.Options.Start"), mso.ToArray(), cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true, selectedIndex: markerStartSelected); var startIndex = mso.IndexOf(start); @@ -32,7 +32,7 @@ public async Task GetUserInput(IEnumerable markerStartOptions, in return new[] { markerStartSelected, markerEndSelected }; var meo = markerEndOptions.ToList(); - var end = await _userInteractionService.ActionSheetAsync(_localizationService.GetString("Svg.Editor.Marker.Options.End"), meo.ToArray(), cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true); + var end = await _userInteractionService.ActionSheetAsync(_localizationService.GetString("Svg.Editor.Marker.Options.End"), meo.ToArray(), cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true, selectedIndex: markerEndSelected); var endIndex = meo.IndexOf(end); if (end == null || endIndex < 0) diff --git a/Svg.Editor.Avalonia.Forms/Services/PinInputService.cs b/Svg.Editor.Avalonia.Forms/Services/PinInputService.cs index b9efd2dbf..acc2205de 100644 --- a/Svg.Editor.Avalonia.Forms/Services/PinInputService.cs +++ b/Svg.Editor.Avalonia.Forms/Services/PinInputService.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Svg.Editor.Avalon.Forms.Dialog; using Svg.Editor.Interfaces; +using Svg.Editor.Services.Localization; using Svg.Editor.Tools; using System; using System.Collections.Generic; @@ -13,20 +14,21 @@ namespace Svg.Editor.Avalon.Forms.Services public class PinInputService : IPinInputService { private readonly IUserInteraction _userInteractionService; - + private readonly ILocalizationService _localizationService; public PinInputService() { _userInteractionService = SvgEngine.Resolve(); + _localizationService = SvgEngine.Resolve(); } public async Task GetUserInput(IEnumerable pinSizeOptions, int oldSizeIndex = 1) { var defaultResult = (PinTool.PinSize)oldSizeIndex; - var sizeResult = await _userInteractionService.ActionSheetAsync("Select pin size", pinSizeOptions.ToArray()); + var sizeResult = await _userInteractionService.ActionSheetAsync(_localizationService.GetString("Svg.Editor.Global.Pin.Size.Selection"), pinSizeOptions.ToArray(), cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true, selectedIndex: oldSizeIndex); PinTool.PinSize.Medium.ToString(); - if (sizeResult == "Cancel") + if (sizeResult == null) { return defaultResult; } diff --git a/Svg.Editor.Avalonia.Forms/Services/StrokeStyleOptionsInputService.cs b/Svg.Editor.Avalonia.Forms/Services/StrokeStyleOptionsInputService.cs index fbcfe3a58..1eef95bb4 100644 --- a/Svg.Editor.Avalonia.Forms/Services/StrokeStyleOptionsInputService.cs +++ b/Svg.Editor.Avalonia.Forms/Services/StrokeStyleOptionsInputService.cs @@ -25,12 +25,12 @@ public StrokeStyleOptionsInputService() int strokeWidthSelected) { var dashes = strokeDashOptions.ToArray(); - var dash = await _userInteractionService.ActionSheetAsync(title, dashes, cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true); + var dash = await _userInteractionService.ActionSheetAsync(title, dashes, cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true, selectedIndex: strokeDashSelected); if (dash == null) return null; var widths = strokeWidthOptions.ToArray(); - var width = await _userInteractionService.ActionSheetAsync(title, widths, cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true); + var width = await _userInteractionService.ActionSheetAsync(title, widths, cancelButton: _localizationService.GetString("Svg.Editor.Global.Cancel"), cancellable: true, selectedIndex: strokeWidthSelected); if (width == null) return null; return new StrokeStyleTool.StrokeStyleOptions() { StrokeDashIndex = Array.IndexOf(dashes, dash), StrokeWidthIndex = Array.IndexOf(widths, width) }; diff --git a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj index c907d0c55..b6ea28ee7 100644 --- a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj +++ b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq05 + 3.2.0-optiq06 + #3.2.0-optiq06 + Improved GetDescendant and ActionSheetDialog has selected item index #3.2.0-optiq05 Improved TextInputService and Colorpicker #3.2.0-optiq04 diff --git a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj index c6fbd6ce2..50d6ce7f8 100644 --- a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj +++ b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq05 + 3.2.0-optiq06 + #3.2.0-optiq06 + Improved GetDescendant and ActionSheetDialog has selected item index #3.2.0-optiq05 Improved TextInputService and Colorpicker #3.2.0-optiq04 diff --git a/Svg.Editor.Core.Tests/Forms/ActionSheetDialogResultViewModelTests.cs b/Svg.Editor.Core.Tests/Forms/ActionSheetDialogResultViewModelTests.cs new file mode 100644 index 000000000..1938a5457 --- /dev/null +++ b/Svg.Editor.Core.Tests/Forms/ActionSheetDialogResultViewModelTests.cs @@ -0,0 +1,242 @@ +using Avalonia.Labs.Controls; +using NUnit.Framework; +using Shouldly; +using Svg.Editor.Avalon.Forms.Dialog.Views; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Svg.Editor.Core.Tests.Forms +{ + [TestFixture] + public class ActionSheetDialogResultViewModelTests + { + private static readonly string[] StringItems = { "Small", "Medium", "Large" }; + + private static readonly List> KvpItems = new() + { + new("Small", 1), + new("Medium", 2), + new("Large", 3), + }; + + [Test] + public void Constructor_WithStringItems_CreatesOneItemPerInput() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + + vm.Items.Count.ShouldBe(StringItems.Length); + } + + [Test] + public void Constructor_WithStringItems_TitleAndValueAreTheSameString() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + + vm.Items.Select(i => i.Title).ShouldBe(StringItems); + vm.Items.Select(i => i.Value).ShouldBe(StringItems.Cast()); + } + + [Test] + public void Constructor_WithStringItems_NoSelectedIndexProvided_SelectsFirstItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + + vm.SelectedItem.ShouldNotBeNull(); + vm.SelectedItem!.Title.ShouldBe("Small"); + } + + [Test] + public void Constructor_WithEmptyStringItems_SelectedItemIsNull() + { + var vm = new ActionSheetDialogResultViewModel(Enumerable.Empty()); + + vm.Items.ShouldBeEmpty(); + vm.SelectedItem.ShouldBeNull(); + } + + + [Test] + public void Constructor_WithValidSelectedIndex_SelectsThatItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: 1); + + vm.SelectedItem.ShouldNotBeNull(); + vm.SelectedItem!.Title.ShouldBe("Medium"); + } + + [Test] + public void Constructor_WithSelectedIndexAtLastPosition_SelectsLastItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: StringItems.Length - 1); + + vm.SelectedItem!.Title.ShouldBe("Large"); + } + + [Test] + public void Constructor_WithNegativeSelectedIndex_FallsBackToFirstItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: -1); + + vm.SelectedItem!.Title.ShouldBe("Small"); + } + + [Test] + public void Constructor_WithSelectedIndexEqualToCount_FallsBackToFirstItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: StringItems.Length); + + vm.SelectedItem!.Title.ShouldBe("Small"); + } + + [Test] + public void Constructor_WithSelectedIndexGreaterThanCount_FallsBackToFirstItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: 999); + + vm.SelectedItem!.Title.ShouldBe("Small"); + } + + [Test] + public void Constructor_WithSelectedIndexOnEmptyItems_SelectedItemIsNull() + { + var vm = new ActionSheetDialogResultViewModel(Enumerable.Empty(), selectedIndex: 0); + + vm.SelectedItem.ShouldBeNull(); + } + + [Test] + public void Constructor_WithKeyValuePairs_CreatesItemsWithCorrectTitleAndValue() + { + var vm = new ActionSheetDialogResultViewModel(KvpItems); + + vm.Items.Count.ShouldBe(KvpItems.Count); + vm.Items.Select(i => i.Title).ShouldBe(KvpItems.Select(k => k.Key)); + vm.Items.Select(i => i.Value).ShouldBe(KvpItems.Select(k => k.Value)); + } + + [Test] + public void Constructor_WithKeyValuePairs_SelectsFirstItemByDefault() + { + var vm = new ActionSheetDialogResultViewModel(KvpItems); + + vm.SelectedItem!.Value.ShouldBe(1); + } + + [Test] + public void GetResult_ReturnsValueOfSelectedItem() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: 2); + + vm.GetResult().ShouldBe("Large"); + } + + [Test] + public void GetResult_WhenSelectedItemIsNull_ReturnsNull() + { + var vm = new ActionSheetDialogResultViewModel(Enumerable.Empty()); + + vm.GetResult().ShouldBeNull(); + } + + [Test] + public void SelectedItem_CanBeChangedAfterConstruction() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + + vm.SelectedItem = vm.Items[2]; + + vm.SelectedItem.ShouldBe(vm.Items[2]); + vm.GetResult().ShouldBe("Large"); + } + + [Test] + public void SelectedItem_Set_RaisesPropertyChanged() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + var raisedProperties = new List(); + + ((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => raisedProperties.Add(e.PropertyName); + + vm.SelectedItem = vm.Items[1]; + + raisedProperties.ShouldContain(nameof(ActionSheetDialogResultViewModel.SelectedItem)); + } + + [Test] + public void SelectedItem_SetToSameValue_DoesNotRaisePropertyChanged() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + var current = vm.SelectedItem; + var raiseCount = 0; + + ((INotifyPropertyChanged)vm).PropertyChanged += (_, _) => raiseCount++; + + vm.SelectedItem = current; + + raiseCount.ShouldBe(0); + } + + [Test] + public void CanClose_PrimaryResultWithSelection_ReturnsTrue() + { + var vm = new ActionSheetDialogResultViewModel(StringItems, selectedIndex: 0); + + var result = InvokeCanClose(vm, ContentDialogResult.Primary); + + result.ShouldBeTrue(); + } + + [Test] + public void CanClose_PrimaryResultWithNoSelection_ReturnsFalse() + { + var vm = new ActionSheetDialogResultViewModel(Enumerable.Empty()); + + var result = InvokeCanClose(vm, ContentDialogResult.Primary); + + result.ShouldBeFalse(); + } + + [Test] + public void CanClose_PrimaryResult_AfterClearingSelection_ReturnsFalse() + { + var vm = new ActionSheetDialogResultViewModel(StringItems); + vm.SelectedItem = null; + + var result = InvokeCanClose(vm, ContentDialogResult.Primary); + + result.ShouldBeFalse(); + } + + private static bool InvokeCanClose(ActionSheetDialogResultViewModel vm, ContentDialogResult result) + { + var method = typeof(ActionSheetDialogResultViewModel) + .GetMethod("CanClose", BindingFlags.NonPublic | BindingFlags.Instance); + + method.ShouldNotBeNull("CanClose method should exist via reflection"); + + return (bool)method!.Invoke(vm, new object[] { result })!; + } + + [Test] + public void ActionSheetItem_ExposesTitleAndValue() + { + var item = new ActionSheetItem("MyTitle", 42); + + item.Title.ShouldBe("MyTitle"); + item.Value.ShouldBe(42); + } + + [Test] + public void ActionSheetItem_AllowsNullValue() + { + var item = new ActionSheetItem("MyTitle", null); + + item.Value.ShouldBeNull(); + } + + } +} diff --git a/Svg.Editor.Core.Tests/MarkerOptionsInputServiceTests.cs b/Svg.Editor.Core.Tests/MarkerOptionsInputServiceTests.cs index 5356857eb..b6e4271de 100644 --- a/Svg.Editor.Core.Tests/MarkerOptionsInputServiceTests.cs +++ b/Svg.Editor.Core.Tests/MarkerOptionsInputServiceTests.cs @@ -51,7 +51,8 @@ public async Task GetUserInput_WithValidSelections_ReturnsSelectedIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("Arrow") // Index 1 .ReturnsAsync("Square"); // Index 2 @@ -81,7 +82,8 @@ public async Task GetUserInput_WithStartMarkerCancellation_ReturnsOriginalIndice It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync((string)null); // User cancels // Act @@ -93,7 +95,7 @@ public async Task GetUserInput_WithStartMarkerCancellation_ReturnsOriginalIndice result[0].ShouldBe(startSelected); result[1].ShouldBe(endSelected); _userInteractionMock.Verify( - x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 2), Times.Once, "Should only call ActionSheetAsync once"); } @@ -113,7 +115,8 @@ public async Task GetUserInput_WithEndMarkerCancellation_ReturnsOriginalIndices( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("Arrow") // User selects start .ReturnsAsync((string)null); // User cancels end @@ -126,8 +129,11 @@ public async Task GetUserInput_WithEndMarkerCancellation_ReturnsOriginalIndices( result[0].ShouldBe(startSelected); result[1].ShouldBe(endSelected); _userInteractionMock.Verify( - x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Exactly(2), "Should call ActionSheetAsync twice"); + x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 1), + Times.Exactly(1), "Should call ActionSheetAsync twice"); + _userInteractionMock.Verify( + x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), 2), + Times.Exactly(1), "Should call ActionSheetAsync twice"); } [Test] @@ -144,7 +150,8 @@ public async Task GetUserInput_WithFirstOptions_ReturnsZeroIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("None") // First option .ReturnsAsync("None"); // First option @@ -173,7 +180,8 @@ public async Task GetUserInput_WithInvalidSelection_ReturnsOriginalIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("InvalidOption"); // Option not in list // Act @@ -199,7 +207,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectStartParameters() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("Arrow") .ReturnsAsync("Diamond"); @@ -214,7 +223,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectStartParameters() It.IsAny(), "Svg.Editor.Global.Cancel", It.IsAny(), - true), + true, + 0), Times.Once); } @@ -232,7 +242,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectEndParameters() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("Arrow") .ReturnsAsync("Diamond"); @@ -247,7 +258,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectEndParameters() It.IsAny(), "Svg.Editor.Global.Cancel", It.IsAny(), - true), + true, + 0), Times.Once); } @@ -265,7 +277,8 @@ public async Task GetUserInput_WithLastOptions_ReturnsCorrectIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("Triangle") // Index 3 .ReturnsAsync("Hexagon"); // Index 4 @@ -294,7 +307,8 @@ public async Task GetUserInput_PreservesSelectedIndicesAcrossMultipleCalls() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync((string)null) // Cancel first time .ReturnsAsync("Arrow") // Select second time .ReturnsAsync("Square"); // Select third time diff --git a/Svg.Editor.Core.Tests/PinInputServiceTests.cs b/Svg.Editor.Core.Tests/PinInputServiceTests.cs new file mode 100644 index 000000000..8c5f9513f --- /dev/null +++ b/Svg.Editor.Core.Tests/PinInputServiceTests.cs @@ -0,0 +1,205 @@ +using Moq; +using NUnit.Framework; +using Shouldly; +using Svg.Editor.Avalon.Forms.Dialog; +using Svg.Editor.Avalon.Forms.Services; +using Svg.Editor.Interfaces; +using Svg.Editor.Tools; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Svg.Editor.Core.Tests +{ + [TestFixture] + public class PinInputServiceTests + { + private Mock _userInteractionMock; + private Mock _localizationServiceMock; + private PinInputService _service; + + private static readonly string[] DefaultOptions = { "Small", "Medium", "Large" }; + + [SetUp] + public void Setup() + { + SvgEditor.Init(); + + _userInteractionMock = new Mock(); + _localizationServiceMock = new Mock(); + + SvgEngine.Register(() => _userInteractionMock.Object); + SvgEngine.Register(() => _localizationServiceMock.Object); + + // Localization just echoes back the key, so we can assert on it directly. + _localizationServiceMock + .Setup(x => x.GetString(It.IsAny(), It.IsAny())) + .Returns((string key, CultureInfo culture) => key); + + _service = new PinInputService(); + } + + private void SetupActionSheet(string returnValue) + { + _userInteractionMock + .Setup(x => x.ActionSheetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(returnValue); + } + + [Test] + public async Task GetUserInput_WhenActionSheetReturnsValidOptionName_ReturnsMatchingPinSize() + { + // Arrange + SetupActionSheet("Medium"); + + // Act + var result = await _service.GetUserInput(DefaultOptions); + + // Assert + result.ShouldBe(PinTool.PinSize.Medium); + } + + [Test] + public async Task GetUserInput_WhenActionSheetReturnsNull_ReturnsDefaultBasedOnOldSizeIndex() + { + // Arrange + SetupActionSheet(null); + + // Act + var result = await _service.GetUserInput(DefaultOptions, oldSizeIndex: 2); + + // Assert + result.ShouldBe((PinTool.PinSize)2); + } + + [Test] + public async Task GetUserInput_WhenActionSheetReturnsUnparsableValue_FallsBackToDefault() + { + // Arrange + SetupActionSheet("NotARealPinSize"); + + // Act + var result = await _service.GetUserInput(DefaultOptions, oldSizeIndex: 0); + + // Assert + result.ShouldBe((PinTool.PinSize)0); + } + + [Test] + public async Task GetUserInput_WhenActionSheetReturnsEmptyString_FallsBackToDefault() + { + // Arrange + SetupActionSheet(string.Empty); + + // Act + var result = await _service.GetUserInput(DefaultOptions, oldSizeIndex: 1); + + // Assert + result.ShouldBe((PinTool.PinSize)1); + } + + [Test] + public async Task GetUserInput_WithoutExplicitOldSizeIndex_DefaultsToIndexOne() + { + // Arrange + SetupActionSheet(null); + + // Act + var result = await _service.GetUserInput(DefaultOptions); + + // Assert + result.ShouldBe((PinTool.PinSize)1); + } + + [Test] + public async Task GetUserInput_PassesLocalizedMessageAndCancelButton_ToActionSheet() + { + // Arrange + string capturedMessage = null; + string capturedCancelButton = null; + bool capturedCancellable = false; + + _userInteractionMock + .Setup(x => x.ActionSheetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, string, CancellationToken?, bool, int>( + (message, options, title, cancelButton, cancelToken, cancellable, defaultIndex) => + { + capturedMessage = message; + capturedCancelButton = cancelButton; + capturedCancellable = cancellable; + }) + .ReturnsAsync("Medium"); + + // Act + await _service.GetUserInput(DefaultOptions); + + // Assert + capturedMessage.ShouldBe("Svg.Editor.Global.Pin.Size.Selection"); + capturedCancelButton.ShouldBe("Svg.Editor.Global.Cancel"); + capturedCancellable.ShouldBeTrue(); + } + + [Test] + public async Task GetUserInput_PassesGivenOptions_ConvertedToArray() + { + // Arrange + var options = new List { "Small", "Medium", "Large" }; + IEnumerable capturedOptions = null; + + _userInteractionMock + .Setup(x => x.ActionSheetAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback, string, string, CancellationToken?, bool, int>( + (message, opts, title, cancelButton, cancelToken, cancellable, defaultIndex) => capturedOptions = opts) + .ReturnsAsync("Medium"); + + // Act + await _service.GetUserInput(options); + + // Assert + capturedOptions.ShouldNotBeNull(); + capturedOptions.ShouldBeOfType(); + capturedOptions.ShouldBe(options); + } + + [Test] + public async Task GetUserInput_CallsLocalizationService_ForSelectionPromptAndCancelButton() + { + // Arrange + SetupActionSheet("Medium"); + + // Act + await _service.GetUserInput(DefaultOptions); + + // Assert + _localizationServiceMock.Verify( + x => x.GetString("Svg.Editor.Global.Pin.Size.Selection", It.IsAny()), + Times.Once); + _localizationServiceMock.Verify( + x => x.GetString("Svg.Editor.Global.Cancel", It.IsAny()), + Times.Once); + } + } +} \ No newline at end of file diff --git a/Svg.Editor.Core.Tests/PinToolTests.cs b/Svg.Editor.Core.Tests/PinToolTests.cs new file mode 100644 index 000000000..f578725ba --- /dev/null +++ b/Svg.Editor.Core.Tests/PinToolTests.cs @@ -0,0 +1,271 @@ +using Microsoft.Reactive.Testing; +using Moq; +using NUnit.Framework; +using Svg.Editor.Core.Test; +using Svg.Editor.Core.Test.Mocks; +using Svg.Editor.Events; +using Svg.Editor.Interfaces; +using Svg.Editor.Services; +using Svg.Editor.Tools; +using Svg.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Svg.Editor.Core.Tests +{ + [TestFixture] + public class PinToolTests : SvgDrawingCanvasTestBase + { + private MockTextInputService _textMock; + private Mock _pinInputServiceMock; + + + override protected void SetupOverride() + { + _pinInputServiceMock = new Mock(); + + var pinToolProperties = new Dictionary + { + {"pinsizenames", new[] {"Small", "Medium", "Large", "ExtraLarge" } } + }; + + Canvas.LoadTools(() => new PinTool(pinToolProperties, SvgEngine.Resolve())); + + + // register mock text input service + _textMock = new MockTextInputService(); + SvgEngine.Register(() => _textMock); + SvgEngine.Register(() => _pinInputServiceMock.Object); + } + + [Test] + public async Task WhenUserLongPresses_CreatesHoleyPinAtDefaultSize() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + var childCountBefore = Canvas.Document.Children.Count; + + // Act + await LongPress(PointF.Create(50, 50)); + + // Assert + Assert.AreEqual(childCountBefore + 1, Canvas.Document.Children.Count); + var pin = Canvas.Document.Children[Canvas.Document.Children.Count - 1]; + Assert.AreEqual("Medium", pin.CustomAttributes[PinTool.PinSizeAttributeKey]); + Assert.AreEqual("Holey", pin.CustomAttributes[PinTool.PinFillAttributeKey]); + } + + [Test] + public async Task WhenUserLongPresses_WithNonDefaultSelectedPinSize_UsesThatSize() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + pinTool.SelectedPinSize = PinTool.PinSize.Large; + + // Act + await LongPress(PointF.Create(50, 50)); + + // Assert + var pin = Canvas.Document.Children[Canvas.Document.Children.Count - 1]; + Assert.AreEqual("Large", pin.CustomAttributes[PinTool.PinSizeAttributeKey]); + } + + [Test] + public async Task WhenUserTapsOnExistingPin_SelectsIt() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + + var position = PointF.Create(50, 50); + await LongPress(position); + var pin = Canvas.Document.Children[Canvas.Document.Children.Count - 1]; + ((TestScheduler)SchedulerProvider.BackgroundScheduler).AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + + // Act + await Tap(position); + + // Assert + CollectionAssert.Contains(Canvas.SelectedElements, pin); + } + + [Test] + public async Task WhenUserTapsOnBlankArea_DoesNotSelectAnything() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + + await LongPress(PointF.Create(50, 50)); + + // Act - tap somewhere well away from the pin we just created + await Tap(PointF.Create(400, 400)); + + // Assert + Assert.IsEmpty(Canvas.SelectedElements); + } + + [Test] + public async Task ChangePinSizeCommand_WithoutSelection_ChangesGlobalSelectedPinSize() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + + _pinInputServiceMock + .Setup(p => p.GetUserInput(It.IsAny>(), It.IsAny())) + .ReturnsAsync(PinTool.PinSize.Large); + + var command = pinTool.Commands.Single(); + + // Act + await WaitForToolCommandsChanged(() => command.Execute(null)); + + // Assert + Assert.AreEqual(PinTool.PinSize.Large, pinTool.SelectedPinSize); + } + + [Test] + public async Task ChangePinSizeCommand_WithSelection_ChangesOnlySelectedPinSize() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + + var position = PointF.Create(50, 50); + await LongPress(position); + var pin = Canvas.Document.Children[Canvas.Document.Children.Count - 1] as SvgVisualElement; + Canvas.SelectedElements.Add(pin); + + var formerGlobalSize = pinTool.SelectedPinSize; + + _pinInputServiceMock + .Setup(p => p.GetUserInput(It.IsAny>(), It.IsAny())) + .ReturnsAsync(PinTool.PinSize.Small); + + var command = pinTool.Commands.Single(); + + // Act + await WaitForCanvasInvalidated(() => command.Execute(null)); + + // Assert + Assert.AreEqual(formerGlobalSize, pinTool.SelectedPinSize, "Global pin size should be untouched when a specific pin is selected"); + var updatedPin = Canvas.Document.Children[Canvas.Document.Children.Count - 1]; + Assert.AreEqual("Small", updatedPin.CustomAttributes[PinTool.PinSizeAttributeKey]); + } + + [Test] + public async Task WhenUserDoubleTapsOnExistingHoleyPin_FillsPinWithEnteredText() + { + // Arrange + await Canvas.EnsureInitialized(); + var pinTool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = pinTool; + + var position = PointF.Create(50, 50); + await LongPress(position); + + // PinTool calls GetUserInput("Please enter 1 or 2 characters.", maxTextLength: 2), + // relying on defaults for the middle params, so MockTextInputService.F only ever + // sees (title, textValue) where textValue is that unset default - not any text we + // actually want typed in. Override F directly to control what comes back. + _textMock.F = (title, textValue) => new TextTool.TextProperties { Text = "AB", FontSizeIndex = 0 }; + + // Act + await DoubleTap(position); + + // Assert + var pin = Canvas.Document.Children[Canvas.Document.Children.Count - 1]; + Assert.AreEqual("Filled", pin.CustomAttributes[PinTool.PinFillAttributeKey]); + } + + // --- Gesture helpers ------------------------------------------------- + + private async Task Tap(PointF position) + { + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, position, position, position, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, position, position, position, 1)); + ((TestScheduler)SchedulerProvider.BackgroundScheduler).AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + } + + private async Task DoubleTap(PointF position) + { + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, position, position, position, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, position, position, position, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, position, position, position, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, position, position, position, 1)); + ((TestScheduler)SchedulerProvider.BackgroundScheduler).AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + } + + // ReactiveGestureRecognizer's long-press buffer (.Buffer(TimeSpan.FromSeconds(LongPressDuration), 2)) + // doesn't take a scheduler argument, unlike the tap/double-tap buffers, so it runs on + // real time and TestScheduler.AdvanceBy has no effect on it. We wait past the actual + // threshold with a real delay instead. LongPressDuration is ~0.66s in the recognizer; + // padded here to reduce flakiness on slower CI machines. + private static readonly TimeSpan LongPressRealDelay = TimeSpan.FromMilliseconds(900); + + private async Task LongPress(PointF position) + { + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, position, position, position, 1)); + await Task.Delay(LongPressRealDelay); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, position, position, position, 1)); + } + + // --- Command synchronization helpers --------------------------------- + // ChangePinSizeCommand.Execute is "async void", so it can't be awaited directly. + // Instead of assuming the mocked GetUserInput task completes its continuation + // synchronously (a Moq implementation detail, not a guarantee), these wait on the + // actual completion signal the command emits, with a timeout as a safety net against + // a hang if that signal is never raised. + + private Task WaitForToolCommandsChanged(Action action, int timeoutMs = 2000) + => WaitForEvent( + h => Canvas.ToolCommandsChanged += h, + h => Canvas.ToolCommandsChanged -= h, + action, timeoutMs, nameof(Canvas.ToolCommandsChanged)); + + private Task WaitForCanvasInvalidated(Action action, int timeoutMs = 2000) + => WaitForEvent( + h => Canvas.CanvasInvalidated += h, + h => Canvas.CanvasInvalidated -= h, + action, timeoutMs, nameof(Canvas.CanvasInvalidated)); + + private static async Task WaitForEvent( + Action subscribe, + Action unsubscribe, + Action action, + int timeoutMs, + string eventNameForError) + { + var tcs = new TaskCompletionSource(); + void Handler(object s, EventArgs e) => tcs.TrySetResult(true); + + subscribe(Handler); + try + { + action(); + var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); + if (completed != tcs.Task) + { + throw new TimeoutException( + $"Timed out after {timeoutMs}ms waiting for {eventNameForError} to fire."); + } + } + finally + { + unsubscribe(Handler); + } + } + } +} diff --git a/Svg.Editor.Core.Tests/StrokeStyleOptionsInputServiceTests.cs b/Svg.Editor.Core.Tests/StrokeStyleOptionsInputServiceTests.cs index d7faca32a..710b99899 100644 --- a/Svg.Editor.Core.Tests/StrokeStyleOptionsInputServiceTests.cs +++ b/Svg.Editor.Core.Tests/StrokeStyleOptionsInputServiceTests.cs @@ -52,7 +52,8 @@ public async Task GetUserInput_WithValidSelections_ReturnsStrokeStyleOptions() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("dashed") // User selects "dashed" (index 1) .ReturnsAsync("thick"); // User selects "thick" (index 2) @@ -79,7 +80,8 @@ public async Task GetUserInput_WithDashCancellation_ReturnsNull() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync((string)null); // User cancels // Act @@ -88,7 +90,7 @@ public async Task GetUserInput_WithDashCancellation_ReturnsNull() // Assert result.ShouldBeNull(); _userInteractionMock.Verify( - x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, "Should only call ActionSheetAsync once for dash options"); } @@ -106,7 +108,8 @@ public async Task GetUserInput_WithWidthCancellation_ReturnsNull() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("dashed") // User selects dash .ReturnsAsync((string)null); // User cancels width @@ -116,7 +119,7 @@ public async Task GetUserInput_WithWidthCancellation_ReturnsNull() // Assert result.ShouldBeNull(); _userInteractionMock.Verify( - x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + x => x.ActionSheetAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2), "Should call ActionSheetAsync twice"); } @@ -134,7 +137,8 @@ public async Task GetUserInput_WithFirstOption_ReturnsZeroIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("solid") // First option .ReturnsAsync("thin"); // First option @@ -162,7 +166,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectParameters() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("dashed") .ReturnsAsync("thick"); @@ -177,7 +182,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectParameters() It.IsAny(), "Svg.Editor.Global.Cancel", It.IsAny(), - true), + true, + 0), Times.Once); _userInteractionMock.Verify( @@ -187,7 +193,8 @@ public async Task GetUserInput_CallsActionSheetWithCorrectParameters() It.IsAny(), "Svg.Editor.Global.Cancel", It.IsAny(), - true), + true, + 0), Times.Once); } @@ -205,7 +212,8 @@ public async Task GetUserInput_WithLastOption_ReturnsCorrectIndices() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), + It.IsAny())) .ReturnsAsync("dashdot") // Index 3 .ReturnsAsync("5px"); // Index 4 diff --git a/Svg.Editor.Core/Services/Localization/Resources/String.de.resx b/Svg.Editor.Core/Services/Localization/Resources/String.de.resx index 1325a749c..cf04dce41 100644 --- a/Svg.Editor.Core/Services/Localization/Resources/String.de.resx +++ b/Svg.Editor.Core/Services/Localization/Resources/String.de.resx @@ -195,4 +195,7 @@ Abbrechen + + Pin-Größe auswählen + \ No newline at end of file diff --git a/Svg.Editor.Core/Services/Localization/Resources/String.fr.resx b/Svg.Editor.Core/Services/Localization/Resources/String.fr.resx index 12c4dde85..517d6ec19 100644 --- a/Svg.Editor.Core/Services/Localization/Resources/String.fr.resx +++ b/Svg.Editor.Core/Services/Localization/Resources/String.fr.resx @@ -195,4 +195,7 @@ Annuler + + Sélectionner la taille de la broche + \ No newline at end of file diff --git a/Svg.Editor.Core/Services/Localization/Resources/String.resx b/Svg.Editor.Core/Services/Localization/Resources/String.resx index f1e79d75d..990419279 100644 --- a/Svg.Editor.Core/Services/Localization/Resources/String.resx +++ b/Svg.Editor.Core/Services/Localization/Resources/String.resx @@ -194,4 +194,7 @@ Cancel + + Select pin size + \ No newline at end of file diff --git a/Svg.Editor.Core/Svg.Editor.Core.csproj b/Svg.Editor.Core/Svg.Editor.Core.csproj index ff030f840..7ab0cd779 100644 --- a/Svg.Editor.Core/Svg.Editor.Core.csproj +++ b/Svg.Editor.Core/Svg.Editor.Core.csproj @@ -4,9 +4,11 @@ Svg.Editor en-US net10.0 - 3.2.0-optiq05 + 3.2.0-optiq06 latest + #3.2.0-optiq06 + Improved GetDescendant and ActionSheetDialog has selected item index #3.2.0-optiq05 Improved TextInputService and Colorpicker #3.2.0-optiq04 diff --git a/Svg.Tests.Win/SvgDocumentTest.cs b/Svg.Tests.Win/SvgDocumentTest.cs index a383fdb8f..a375a3fe6 100644 --- a/Svg.Tests.Win/SvgDocumentTest.cs +++ b/Svg.Tests.Win/SvgDocumentTest.cs @@ -1,5 +1,7 @@ using NUnit.Framework; using Shouldly; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; @@ -15,6 +17,8 @@ public void SetUp() SvgPlatform.Init(); } + + [Test] public void WhenSvgDocumentDrawsAllContent_ThenBitmapHasBoundsSize() { @@ -367,5 +371,136 @@ public void WhenSvgUse_UsesClipPathAndTransform_AndContentIsClipped_CalculatesBo bounds.Height.ShouldBe(170); bounds.Width.ShouldBe(180); } + + [Test] + public void WhenCallingDescendants_ThenAllElementsAreReturnedInDocumentOrder() + { + // Arrange + var rawSvg = $@" + + + + + + + + +"; + var svg = SvgDocument.FromSvg(rawSvg); + + // Act + var descendants = new[] { (SvgElement)svg }.Descendants().ToList(); + + // Assert + var expectedIds = new[] { "group1", "rect1", "rect2", "group2", "circle1" }; + descendants.Select(e => e.ID).ShouldBe(expectedIds); + } + + [Test] + public void WhenCallingDescendants_ThenTheRootDocumentItselfIsNotIncluded() + { + // Arrange + var rawSvg = $@" + + +"; + var svg = SvgDocument.FromSvg(rawSvg); + + // Act + var descendants = new[] { (SvgElement)svg }.Descendants().ToList(); + + // Assert + descendants.ShouldNotContain(svg); + descendants.Select(e => e.ID).ShouldBe(new[] { "rect1" }); + } + + [Test] + public void WhenCallingDescendants_OnElementWithNoChildren_ThenResultIsEmpty() + { + // Arrange + var rawSvg = $@" + + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = svg.Children.OfType().Single(); + + // Act + var descendants = new[] { (SvgElement)rect }.Descendants().ToList(); + + // Assert + descendants.ShouldBeEmpty(); + } + + [Test] + public void WhenCallingDescendants_WithNestedGroups_ThenDeepDescendantsAreIncluded() + { + // Arrange + var rawSvg = $@" + + + + + + +"; + var svg = SvgDocument.FromSvg(rawSvg); + + // Act + var descendants = new[] { (SvgElement)svg }.Descendants().ToList(); + + // Assert + descendants.Select(e => e.ID).ShouldBe(new[] { "outer", "inner", "deepRect" }); + } + + [Test] + public void WhenCallingDescendants_OnCssStyledDocument_ThenAllTextElementsAreFound() + { + // Reuses the same document as WhenDocumentOpenWithCss_ElementsAdaptStyle, + // but goes through the public extension method instead of GetDescendants() + // Arrange + var rawSvg = $@" + + + 01 + 02 + 03 +"; + var svg = SvgDocument.FromSvg(rawSvg); + + // Act + var texts = new[] { (SvgElement)svg }.Descendants().OfType().ToArray(); + + // Assert + texts.Length.ShouldBe(3); + texts.Select(t => t.Text.Trim()).ShouldBe(new[] { "01", "02", "03" }); + } + + [Test] + public void WhenCallingDescendants_WithMultipleRootDocuments_ThenEachIsTraversedIndependently() + { + // Arrange + var svg1 = SvgDocument.FromSvg(@""); + var svg2 = SvgDocument.FromSvg(@""); + + // Act + var descendants = new[] { (SvgElement)svg1, svg2 }.Descendants().ToList(); + + // Assert + descendants.Select(e => e.ID).ShouldBe(new[] { "a1", "b1", "b2" }); + } + + [Test] + public void WhenCallingDescendants_WithNullSource_ThenArgumentNullExceptionIsThrown() + { + // Arrange + IEnumerable source = null; + + // Act / Assert + Should.Throw(() => source.Descendants().ToList()); + } } } \ No newline at end of file diff --git a/Svg/Extensions.cs b/Svg/Extensions.cs index 4fbb0d9ff..6647bf295 100644 --- a/Svg/Extensions.cs +++ b/Svg/Extensions.cs @@ -31,38 +31,32 @@ private static IEnumerable GetAncestors(IEnumerable source, bo } private static IEnumerable GetDescendants(IEnumerable source, bool self) where T : SvgElement { - var positons = new Stack(); - int currPos; - SvgElement currParent; foreach (var start in source) { - if (start != null) - { - if (self) yield return start; + if (start == null) continue; + + if (self) yield return start; + + var stack = new Stack<(SvgElement node, int index)>(); + stack.Push((start, 0)); - positons.Push(0); - currParent = start; + while (stack.Count > 0) + { + var (node, index) = stack.Pop(); - while (positons.Count > 0) + if (index < node.Children.Count) { - currPos = positons.Pop(); - if (currPos < currParent.Children.Count) - { - yield return currParent.Children[currPos]; - currParent = currParent.Children[currPos]; - positons.Push(currPos + 1); - positons.Push(0); - } - else - { - currParent = currParent.Parent; - } + var child = node.Children[index]; + yield return child; + + stack.Push((node, index + 1)); + stack.Push((child, 0)); } + // else: just drop this frame, no need to touch .Parent at all } } - yield break; } - + private static ICharConverter _converter = null; public static string ConvertFromUtf32(this int value) diff --git a/Svg/Svg.csproj b/Svg/Svg.csproj index de6ee5698..0bd909b58 100644 --- a/Svg/Svg.csproj +++ b/Svg/Svg.csproj @@ -3,10 +3,12 @@ netstandard2.0;net48;net10.0 PackageReference - 3.2.0-optiq05 + 3.2.0-optiq06 gentledpp,zepr Opti-Q GmbH + #3.2.0-optiq06 + Improved GetDescendant and ActionSheetDialog has selected item index #3.2.0-optiq05 Improved TextInputService and Colorpicker #3.2.0-optiq04