From 58f5ac24e22977928196f43ab65752dec124d717 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 22 May 2026 00:40:40 +0200 Subject: [PATCH] Sort results setting reaches the search pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisplaySettings.SortResults was persisted but unused — RunningSearch hardcoded ComparerByFitness. Now SearchPaneModel.RestartSearch reads the setting at start-of-search and passes either ComparerByFitness (default) or ComparerByName into RunningSearch. Capturing at start matches WPF — mid-run toggles only affect the next search. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Search/SearchResultSortOrderTests.cs | 91 +++++++++++++++++++ ILSpy/Search/RunningSearch.cs | 17 ++-- ILSpy/Search/SearchPaneModel.cs | 9 +- 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 ILSpy.Tests/Search/SearchResultSortOrderTests.cs diff --git a/ILSpy.Tests/Search/SearchResultSortOrderTests.cs b/ILSpy.Tests/Search/SearchResultSortOrderTests.cs new file mode 100644 index 000000000..4ad417872 --- /dev/null +++ b/ILSpy.Tests/Search/SearchResultSortOrderTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.Search; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Search; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +[TestFixture] +public class SearchResultSortOrderTests +{ + [AvaloniaTest] + public async Task When_SortResults_Is_False_Results_Are_Ordered_By_Name_Not_Fitness() + { + // The "Sort results by fitness" checkbox in Display Settings must reach the search + // pipeline. Default is true (rank by Fitness desc); flipping to false must rank by + // Name asc (StringComparer.Ordinal). Mirrors WPF's SearchPane.xaml.cs:288-290 + // which captures the comparer at start-of-search based on DisplaySettings.SortResults. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var settings = AppComposition.Current.GetExport(); + var originalSortResults = settings.DisplaySettings.SortResults; + + try + { + settings.DisplaySettings.SortResults = false; + + var search = AppComposition.Current.GetExport(); + search.SearchTerm = string.Empty; + search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type); + search.SearchTerm = "Enumerable"; + + await Waiters.WaitForAsync( + () => !search.IsSearching && search.Results.Count >= 2, + timeout: TimeSpan.FromSeconds(30)); + + // Drop assembly/namespace results — they share a unit Fitness with all peers in + // their bucket, so any tie-break is ambiguous between fitness- and name-sort. + // Member results are where Fitness varies (1/Name.Length), so their order is + // what distinguishes the two comparers. + var names = search.Results.OfType() + .Select(r => r.Name) + .ToList(); + + names.Should().HaveCountGreaterThan(1, + "need at least two member hits to compare orderings"); + names.Should().BeInAscendingOrder(StringComparer.Ordinal, + "with SortResults=false the pane must rank by Name ordinal-asc, not by Fitness"); + + search.SearchTerm = string.Empty; + } + finally + { + settings.DisplaySettings.SortResults = originalSortResults; + } + } +} diff --git a/ILSpy/Search/RunningSearch.cs b/ILSpy/Search/RunningSearch.cs index 4708a4789..c7a83efde 100644 --- a/ILSpy/Search/RunningSearch.cs +++ b/ILSpy/Search/RunningSearch.cs @@ -59,6 +59,7 @@ namespace ILSpy.Search readonly ApiVisibility apiVisibility; readonly ISearchResultFactory resultFactory; readonly ObservableCollection sink; + readonly IComparer sortComparer; readonly ConcurrentQueue queue = new(); readonly CancellationTokenSource cts = new(); DispatcherTimer? drainTimer; @@ -74,7 +75,8 @@ namespace ILSpy.Search Language language, ApiVisibility apiVisibility, ISearchResultFactory resultFactory, - ObservableCollection sink) + ObservableCollection sink, + IComparer sortComparer) { this.assemblies = assemblies; this.searchTerm = searchTerm; @@ -83,6 +85,7 @@ namespace ILSpy.Search this.apiVisibility = apiVisibility; this.resultFactory = resultFactory; this.sink = sink; + this.sortComparer = sortComparer; } public bool IsCompleted => runTask is { IsCompleted: true }; @@ -201,12 +204,12 @@ namespace ILSpy.Search { if (!queue.TryDequeue(out var result)) break; - // Sorted insert: results land in fitness order so the most relevant hit - // rises to the top while later, less relevant matches are still streaming - // in. ObservableCollection implements IList, so the InsertSorted - // extension binary-searches and Inserts at the right index — O(log n) - // compare + O(n) shift. - sink.InsertSorted(result, SearchResult.ComparerByFitness); + // Sorted insert against the run's captured comparer. The Display-Settings + // "Sort results by fitness" checkbox picks between ComparerByFitness (the + // default — shorter names rise to the top) and ComparerByName (ordinal asc). + // ObservableCollection implements IList, so the InsertSorted extension + // binary-searches and Inserts at the right index — O(log n) compare + O(n) shift. + sink.InsertSorted(result, sortComparer); } if (sink.Count >= MaxResults) diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index 176a6f35b..038b6eb18 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -196,6 +196,12 @@ namespace ILSpy.Search ?? ApiVisibility.PublicOnly; var factory = new AvaloniaSearchResultFactory(language); + // Capture the comparer at start-of-search (matches WPF SearchPane.xaml.cs:288). + // Toggling "Sort results by fitness" mid-run won't reshuffle results already on + // screen — it only takes effect on the next search. + var sortComparer = (TryGetSettings()?.DisplaySettings.SortResults ?? true) + ? SearchResult.ComparerByFitness + : SearchResult.ComparerByName; var run = new RunningSearch( assemblyList.GetAssemblies(), term, @@ -203,7 +209,8 @@ namespace ILSpy.Search language, apiVisibility, factory, - Results); + Results, + sortComparer); run.Completed += OnRunCompleted; currentSearch = run; IsSearching = true;