pacsea/logic/
sort.rs

1//! Result sorting with selection preservation across sort modes.
2//!
3//! Implements cache-based O(n) reordering for sort mode switching between cacheable modes.
4//! `BestMatches` mode is query-dependent and always performs full O(n log n) sort.
5
6use crate::state::{AppState, PackageItem, SortMode, Source};
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9#[cfg(test)]
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12#[cfg(test)]
13static COMPUTE_REPO_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
14#[cfg(test)]
15static COMPUTE_AUR_INDICES_CALLS: AtomicUsize = AtomicUsize::new(0);
16
17/// What: Compute a signature hash for the results list to validate cache validity.
18///
19/// Inputs:
20/// - `results`: Slice of package items to compute signature for.
21///
22/// Output:
23/// - Returns an order-insensitive `u64` hash based on package names.
24///
25/// Details:
26/// - Used to detect when results have changed, invalidating cached sort orders.
27/// - Order-insensitive so mode switches do not invalidate caches.
28fn compute_results_signature(results: &[PackageItem]) -> u64 {
29    // Collect and canonicalize names to be order-insensitive.
30    let mut names: Vec<&str> = results.iter().map(|p| p.name.as_str()).collect();
31    names.sort_unstable();
32
33    let mut hasher = DefaultHasher::new();
34    names.len().hash(&mut hasher);
35
36    // Mix first/last to avoid hashing full list twice.
37    if let Some(first) = names.first() {
38        first.hash(&mut hasher);
39    }
40    if let Some(last) = names.last() {
41        last.hash(&mut hasher);
42    }
43
44    // Aggregate individual name hashes in an order-insensitive way.
45    let mut aggregate: u64 = 0;
46    for name in names {
47        let mut nh = DefaultHasher::new();
48        name.hash(&mut nh);
49        aggregate ^= nh.finish();
50    }
51    aggregate.hash(&mut hasher);
52
53    hasher.finish()
54}
55
56/// What: Reorder results vector using cached indices.
57///
58/// Inputs:
59/// - `results`: Mutable reference to results vector.
60/// - `indices`: Slice of indices representing the desired sort order.
61///
62/// Output:
63/// - Reorders `results` in-place according to `indices`.
64///
65/// Details:
66/// - Performs O(n) reordering instead of O(n log n) sorting.
67/// - Invalid indices are filtered out safely.
68fn reorder_from_indices(results: &mut Vec<PackageItem>, indices: &[usize]) {
69    let reordered: Vec<PackageItem> = indices
70        .iter()
71        .filter_map(|&i| results.get(i).cloned())
72        .collect();
73    *results = reordered;
74}
75
76/// What: Sort results by best match rank based on query.
77///
78/// Inputs:
79/// - `results`: Mutable reference to results vector.
80/// - `query`: Search query string for match ranking.
81///
82/// Output:
83/// - Sorts results in-place by match rank (lower is better), with repo order and name as tiebreakers.
84///
85/// Details:
86/// - Used for `BestMatches` sort mode. Query-dependent, so cannot be cached.
87fn sort_best_matches(results: &mut [PackageItem], query: &str) {
88    let ql = query.trim().to_lowercase();
89    results.sort_by(|a, b| {
90        let ra = crate::util::match_rank(&a.name, &ql);
91        let rb = crate::util::match_rank(&b.name, &ql);
92        if ra != rb {
93            return ra.cmp(&rb);
94        }
95        // Tiebreak: keep pacman repo order first to keep layout familiar
96        let oa = crate::util::repo_order(&a.source);
97        let ob = crate::util::repo_order(&b.source);
98        if oa != ob {
99            return oa.cmp(&ob);
100        }
101        a.name.to_lowercase().cmp(&b.name.to_lowercase())
102    });
103}
104
105/// What: Compute sort order indices for repo-then-name sorting.
106///
107/// Inputs:
108/// - `results`: Slice of package items.
109///
110/// Output:
111/// - Returns vector of indices representing sorted order.
112///
113/// Details:
114/// - Used to populate cache without modifying the original results.
115fn compute_repo_then_name_indices(results: &[PackageItem]) -> Vec<usize> {
116    #[cfg(test)]
117    COMPUTE_REPO_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
118
119    let mut indices: Vec<usize> = (0..results.len()).collect();
120    indices.sort_by(|&i, &j| {
121        let a = &results[i];
122        let b = &results[j];
123        let oa = crate::util::repo_order(&a.source);
124        let ob = crate::util::repo_order(&b.source);
125        if oa != ob {
126            return oa.cmp(&ob);
127        }
128        a.name.to_lowercase().cmp(&b.name.to_lowercase())
129    });
130    indices
131}
132
133/// What: Compute sort order indices for AUR-popularity-then-official sorting.
134///
135/// Inputs:
136/// - `results`: Slice of package items.
137///
138/// Output:
139/// - Returns vector of indices representing sorted order.
140///
141/// Details:
142/// - Used to populate cache without modifying the original results.
143fn compute_aur_popularity_then_official_indices(results: &[PackageItem]) -> Vec<usize> {
144    #[cfg(test)]
145    COMPUTE_AUR_INDICES_CALLS.fetch_add(1, Ordering::Relaxed);
146
147    let mut indices: Vec<usize> = (0..results.len()).collect();
148    indices.sort_by(|&i, &j| {
149        let a = &results[i];
150        let b = &results[j];
151        // AUR first
152        let aur_a = matches!(a.source, Source::Aur);
153        let aur_b = matches!(b.source, Source::Aur);
154        if aur_a != aur_b {
155            return aur_b.cmp(&aur_a); // true before false
156        }
157        if aur_a && aur_b {
158            // Desc popularity for AUR
159            let pa = a.popularity.unwrap_or(0.0);
160            let pb = b.popularity.unwrap_or(0.0);
161            if (pa - pb).abs() > f64::EPSILON {
162                return pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal);
163            }
164        } else {
165            // Both official: keep pacman order (repo_order), then name
166            let oa = crate::util::repo_order(&a.source);
167            let ob = crate::util::repo_order(&b.source);
168            if oa != ob {
169                return oa.cmp(&ob);
170            }
171        }
172        a.name.to_lowercase().cmp(&b.name.to_lowercase())
173    });
174    indices
175}
176
177/// What: Apply the currently selected sorting mode to `app.results` in-place.
178///
179/// Inputs:
180/// - `app`: Mutable application state (`results`, `selected`, `input`, `sort_mode`)
181///
182/// Output:
183/// - Sorts `app.results` and preserves selection by name when possible; otherwise clamps index.
184///
185/// Details:
186/// - Uses cache-based O(n) reordering when switching between cacheable modes (`RepoThenName` and `AurPopularityThenOfficial`).
187/// - Performs full O(n log n) sort when cache is invalid or for `BestMatches` mode.
188/// - Populates both cache orders eagerly after full sort to enable instant mode switching.
189pub fn sort_results_preserve_selection(app: &mut AppState) {
190    if app.results.is_empty() {
191        return;
192    }
193    let prev_name = app.results.get(app.selected).map(|p| p.name.clone());
194
195    // Compute current signature to check cache validity
196    let current_sig = compute_results_signature(&app.results);
197
198    // Check if cache is valid and we can use O(n) reordering
199    let cache_valid = app.sort_cache_signature == Some(current_sig);
200
201    match app.sort_mode {
202        SortMode::RepoThenName => {
203            if cache_valid {
204                if let Some(ref indices) = app.sort_cache_repo_name {
205                    // Cache hit: O(n) reorder
206                    reorder_from_indices(&mut app.results, indices);
207                } else {
208                    // Cache miss: compute indices from current state, then reorder
209                    let indices = compute_repo_then_name_indices(&app.results);
210                    reorder_from_indices(&mut app.results, &indices);
211                }
212            } else {
213                // Cache invalid: compute indices from current state, then reorder
214                let indices = compute_repo_then_name_indices(&app.results);
215                reorder_from_indices(&mut app.results, &indices);
216            }
217            // Re-anchor caches to current order to keep future switches correct.
218            app.sort_cache_repo_name = Some((0..app.results.len()).collect());
219            app.sort_cache_aur_popularity =
220                Some(compute_aur_popularity_then_official_indices(&app.results));
221            app.sort_cache_signature = Some(current_sig);
222        }
223        SortMode::AurPopularityThenOfficial => {
224            if cache_valid {
225                if let Some(ref indices) = app.sort_cache_aur_popularity {
226                    // Cache hit: O(n) reorder
227                    reorder_from_indices(&mut app.results, indices);
228                } else {
229                    // Cache miss: compute indices from current state, then reorder
230                    let indices = compute_aur_popularity_then_official_indices(&app.results);
231                    reorder_from_indices(&mut app.results, &indices);
232                }
233            } else {
234                // Cache invalid: compute indices from current state, then reorder
235                let indices = compute_aur_popularity_then_official_indices(&app.results);
236                reorder_from_indices(&mut app.results, &indices);
237            }
238            // Re-anchor caches to current order to keep future switches correct.
239            app.sort_cache_repo_name = Some(compute_repo_then_name_indices(&app.results));
240            app.sort_cache_aur_popularity = Some((0..app.results.len()).collect());
241            app.sort_cache_signature = Some(current_sig);
242        }
243        SortMode::BestMatches => {
244            // BestMatches is query-dependent, always do full sort and don't cache
245            sort_best_matches(&mut app.results, &app.input);
246            // Clear mode-specific caches since BestMatches can't use them
247            app.sort_cache_repo_name = None;
248            app.sort_cache_aur_popularity = None;
249            app.sort_cache_signature = None;
250        }
251    }
252
253    // Restore selection by name
254    if let Some(name) = prev_name {
255        if let Some(pos) = app.results.iter().position(|p| p.name == name) {
256            app.selected = pos;
257            app.list_state.select(Some(pos));
258        } else {
259            app.selected = app.selected.min(app.results.len().saturating_sub(1));
260            app.list_state.select(Some(app.selected));
261        }
262    }
263}
264
265/// What: Invalidate all sort caches.
266///
267/// Inputs:
268/// - `app`: Mutable application state.
269///
270/// Output:
271/// - Clears all sort cache fields.
272///
273/// Details:
274/// - Should be called when results change (new search, filter change, etc.).
275pub fn invalidate_sort_caches(app: &mut AppState) {
276    app.sort_cache_repo_name = None;
277    app.sort_cache_aur_popularity = None;
278    app.sort_cache_signature = None;
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[cfg(test)]
286    /// What: Reset compute index call counters used for instrumentation in tests.
287    ///
288    /// Inputs:
289    /// - None.
290    ///
291    /// Output:
292    /// - Clears the atomic counters to zero.
293    ///
294    /// Details:
295    /// - Keeps tests isolated by removing cross-test coupling from shared state.
296    fn reset_compute_counters() {
297        COMPUTE_REPO_INDICES_CALLS.store(0, Ordering::SeqCst);
298        COMPUTE_AUR_INDICES_CALLS.store(0, Ordering::SeqCst);
299    }
300
301    fn item_official(name: &str, repo: &str) -> crate::state::PackageItem {
302        crate::state::PackageItem {
303            name: name.to_string(),
304            version: "1.0".to_string(),
305            description: format!("{name} desc"),
306            source: crate::state::Source::Official {
307                repo: repo.to_string(),
308                arch: "x86_64".to_string(),
309            },
310            popularity: None,
311            out_of_date: None,
312            orphaned: false,
313        }
314    }
315    fn item_aur(name: &str, pop: Option<f64>) -> crate::state::PackageItem {
316        crate::state::PackageItem {
317            name: name.to_string(),
318            version: "1.0".to_string(),
319            description: format!("{name} desc"),
320            source: crate::state::Source::Aur,
321            popularity: pop,
322            out_of_date: None,
323            orphaned: false,
324        }
325    }
326
327    #[test]
328    /// What: Confirm sorting preserves the selected index while adjusting order across modes, including relevance matching.
329    ///
330    /// Inputs:
331    /// - Mixed list of official and AUR results.
332    /// - Sort mode toggled through `RepoThenName`, `AurPopularityThenOfficial`, and `BestMatches` with input `"bb"`.
333    ///
334    /// Output:
335    /// - Selection remains on the prior package and ordering reflects repo priority, popularity preference, and match rank, respectively.
336    ///
337    /// Details:
338    /// - Ensures the UI behaviour stays predictable when users toggle sort modes after highlighting a result.
339    fn sort_preserve_selection_and_best_matches() {
340        let mut app = AppState {
341            results: vec![
342                item_aur("zzz", Some(1.0)),
343                item_official("aaa", "core"),
344                item_official("bbb", "extra"),
345                item_aur("ccc", Some(10.0)),
346            ],
347            selected: 2,
348            sort_mode: SortMode::RepoThenName,
349            ..Default::default()
350        };
351        app.list_state.select(Some(2));
352        sort_results_preserve_selection(&mut app);
353        assert_eq!(
354            app.results
355                .iter()
356                .filter(|p| matches!(p.source, Source::Official { .. }))
357                .count(),
358            2
359        );
360        assert_eq!(app.results[app.selected].name, "bbb");
361
362        app.sort_mode = SortMode::AurPopularityThenOfficial;
363        sort_results_preserve_selection(&mut app);
364        let aur_first = &app.results[0];
365        assert!(matches!(aur_first.source, Source::Aur));
366
367        app.input = "bb".into();
368        app.sort_mode = SortMode::BestMatches;
369        sort_results_preserve_selection(&mut app);
370        assert!(
371            app.results
372                .iter()
373                .position(|p| p.name.contains("bb"))
374                .expect("should find package containing 'bb' in test data")
375                <= 1
376        );
377    }
378
379    #[test]
380    /// What: Validate `BestMatches` tiebreakers prioritise repo order before lexicographic name sorting.
381    ///
382    /// Inputs:
383    /// - Three official packages whose names share the `alpha` prefix across `core` and `extra` repos.
384    ///
385    /// Output:
386    /// - Sorted list begins with the `core` repo entry, followed by `extra` items in name order.
387    ///
388    /// Details:
389    /// - Captures the layered tiebreak logic to catch regressions if repo precedence changes.
390    fn sort_bestmatches_tiebreak_repo_then_name() {
391        let mut app = AppState {
392            results: vec![
393                item_official("alpha2", "extra"),
394                item_official("alpha1", "extra"),
395                item_official("alpha_core", "core"),
396            ],
397            input: "alpha".into(),
398            sort_mode: SortMode::BestMatches,
399            ..Default::default()
400        };
401        sort_results_preserve_selection(&mut app);
402        let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
403        assert_eq!(names, vec!["alpha_core", "alpha1", "alpha2"]);
404    }
405
406    #[test]
407    /// What: Ensure results signature is order-insensitive but content-sensitive.
408    ///
409    /// Inputs:
410    /// - Same set of packages in different orders.
411    /// - A variant with an extra package.
412    ///
413    /// Output:
414    /// - Signatures match for permutations and differ when content changes.
415    ///
416    /// Details:
417    /// - Guards cache reuse when switching sort modes without masking real result changes.
418    fn results_signature_is_order_insensitive() {
419        let base = vec![
420            item_official("aaa", "core"),
421            item_official("bbb", "extra"),
422            item_official("ccc", "community"),
423        ];
424        let permuted = vec![
425            item_official("ccc", "community"),
426            item_official("aaa", "core"),
427            item_official("bbb", "extra"),
428        ];
429        let mut extended = permuted.clone();
430        extended.push(item_official("ddd", "community"));
431
432        let sig_base = compute_results_signature(&base);
433        let sig_permuted = compute_results_signature(&permuted);
434        let sig_extended = compute_results_signature(&extended);
435
436        assert_eq!(sig_base, sig_permuted);
437        assert_ne!(sig_base, sig_extended);
438    }
439
440    #[test]
441    /// What: Ensure the AUR popularity sort orders helpers by descending popularity with deterministic tie-breaks.
442    ///
443    /// Inputs:
444    /// - AUR items sharing the same popularity value and official entries from different repos.
445    ///
446    /// Output:
447    /// - AUR items sorted by name when popularity ties, followed by official packages prioritising `core` before `extra`.
448    ///
449    /// Details:
450    /// - Verifies the composite comparator remains stable for UI diffs and regression detection.
451    fn sort_aur_popularity_and_official_tiebreaks() {
452        let mut app = AppState {
453            results: vec![
454                item_aur("aurB", Some(1.0)),
455                item_aur("aurA", Some(1.0)),
456                item_official("z_off", "core"),
457                item_official("a_off", "extra"),
458            ],
459            sort_mode: SortMode::AurPopularityThenOfficial,
460            ..Default::default()
461        };
462        sort_results_preserve_selection(&mut app);
463        let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
464        assert_eq!(names, vec!["aurA", "aurB", "z_off", "a_off"]);
465    }
466
467    #[test]
468    /// What: Verify cache invalidation clears all sort cache fields.
469    ///
470    /// Inputs:
471    /// - `AppState` with manually set cache fields.
472    ///
473    /// Output:
474    /// - All cache fields are `None` after invalidation.
475    ///
476    /// Details:
477    /// - Tests that `invalidate_sort_caches` properly clears all cache state.
478    fn sort_cache_invalidation() {
479        let mut app = AppState {
480            results: vec![
481                item_official("pkg1", "core"),
482                item_official("pkg2", "extra"),
483            ],
484            sort_mode: SortMode::RepoThenName,
485            sort_cache_signature: Some(12345),
486            sort_cache_repo_name: Some(vec![0, 1]),
487            sort_cache_aur_popularity: Some(vec![1, 0]),
488            ..Default::default()
489        };
490
491        // Invalidate cache
492        invalidate_sort_caches(&mut app);
493        assert!(app.sort_cache_signature.is_none());
494        assert!(app.sort_cache_repo_name.is_none());
495        assert!(app.sort_cache_aur_popularity.is_none());
496    }
497
498    #[test]
499    /// What: Verify `BestMatches` mode does not populate mode-specific caches.
500    ///
501    /// Inputs:
502    /// - Results list sorted with `BestMatches` mode.
503    ///
504    /// Output:
505    /// - Mode-specific caches remain `None` for `BestMatches`.
506    ///
507    /// Details:
508    /// - `BestMatches` depends on the query and should not cache mode-specific indices.
509    fn sort_bestmatches_no_mode_cache() {
510        let mut app = AppState {
511            results: vec![
512                item_official("alpha", "core"),
513                item_official("beta", "extra"),
514            ],
515            input: "alph".into(),
516            sort_mode: SortMode::BestMatches,
517            ..Default::default()
518        };
519
520        sort_results_preserve_selection(&mut app);
521
522        // BestMatches should not populate mode-specific caches
523        assert!(app.sort_cache_repo_name.is_none());
524        assert!(app.sort_cache_aur_popularity.is_none());
525    }
526
527    #[test]
528    /// What: Verify cache hit path uses O(n) reordering when cache is valid.
529    ///
530    /// Inputs:
531    /// - Results with valid cache signature and cached indices for `RepoThenName`.
532    ///
533    /// Output:
534    /// - Results are reordered using cached indices without full sort.
535    ///
536    /// Details:
537    /// - Tests that cache-based optimization works correctly.
538    fn sort_cache_hit_repo_then_name() {
539        let mut app = AppState {
540            results: vec![
541                item_official("zzz", "extra"),
542                item_official("aaa", "core"),
543                item_official("bbb", "core"),
544            ],
545            sort_mode: SortMode::RepoThenName,
546            ..Default::default()
547        };
548
549        // First sort to populate cache
550        sort_results_preserve_selection(&mut app);
551        let first_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
552        let cached_sig = app.sort_cache_signature;
553
554        // Change to different order
555        app.sort_mode = SortMode::AurPopularityThenOfficial;
556        sort_results_preserve_selection(&mut app);
557
558        // Switch back - should use cache
559        app.sort_mode = SortMode::RepoThenName;
560        sort_results_preserve_selection(&mut app);
561        let second_sort_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
562
563        // Should match first sort order
564        assert_eq!(first_sort_order, second_sort_order);
565        assert_eq!(app.sort_cache_signature, cached_sig);
566    }
567
568    #[test]
569    /// What: Verify cache miss path performs full sort when results change.
570    ///
571    /// Inputs:
572    /// - Results with cached signature that doesn't match current results.
573    ///
574    /// Output:
575    /// - Full sort is performed and cache is repopulated.
576    ///
577    /// Details:
578    /// - Tests that cache invalidation works correctly.
579    fn sort_cache_miss_on_results_change() {
580        let mut app = AppState {
581            results: vec![item_official("aaa", "core"), item_official("bbb", "extra")],
582            sort_mode: SortMode::RepoThenName,
583            ..Default::default()
584        };
585
586        // First sort to populate cache
587        sort_results_preserve_selection(&mut app);
588        let old_sig = app.sort_cache_signature;
589
590        // Change results (simulating new search)
591        app.results = vec![item_official("ccc", "core"), item_official("ddd", "extra")];
592
593        // Sort again - should detect cache miss and repopulate
594        sort_results_preserve_selection(&mut app);
595        let new_sig = app.sort_cache_signature;
596
597        // Signature should be different
598        assert_ne!(old_sig, new_sig);
599        assert!(app.sort_cache_repo_name.is_some());
600        assert!(app.sort_cache_aur_popularity.is_some());
601    }
602
603    #[test]
604    /// What: Ensure cache invalidation only computes current-mode indices once while rebuilding caches.
605    ///
606    /// Inputs:
607    /// - Results with a deliberately mismatched cache signature to force invalidation.
608    ///
609    /// Output:
610    /// - Current-mode index computation runs once; cross-mode cache computation still occurs once after reorder.
611    ///
612    /// Details:
613    /// - Guards against redundant index work when cache signatures are stale.
614    fn sort_cache_invalid_computes_indices_once() {
615        reset_compute_counters();
616        let mut app = AppState {
617            results: vec![item_official("bbb", "extra"), item_official("aaa", "core")],
618            sort_mode: SortMode::RepoThenName,
619            ..Default::default()
620        };
621
622        // Force signature mismatch to hit invalidation path.
623        let sig = compute_results_signature(&app.results);
624        app.sort_cache_signature = Some(sig.wrapping_add(1));
625
626        sort_results_preserve_selection(&mut app);
627
628        assert_eq!(
629            COMPUTE_REPO_INDICES_CALLS.load(Ordering::SeqCst),
630            1,
631            "repo indices should be computed exactly once on cache invalidation"
632        );
633        assert_eq!(
634            COMPUTE_AUR_INDICES_CALLS.load(Ordering::SeqCst),
635            1,
636            "aur indices should be recomputed once to re-anchor caches"
637        );
638        let names: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
639        assert_eq!(names, vec!["aaa", "bbb"]);
640    }
641
642    #[test]
643    /// What: Verify switching between cacheable modes uses cached indices.
644    ///
645    /// Inputs:
646    /// - Results sorted in `RepoThenName` mode with populated caches.
647    ///
648    /// Output:
649    /// - Switching to `AurPopularityThenOfficial` uses cached indices for O(n) reordering.
650    ///
651    /// Details:
652    /// - Tests the main optimization: instant mode switching via cache.
653    fn sort_cache_mode_switching() {
654        let mut app = AppState {
655            results: vec![
656                item_aur("low_pop", Some(1.0)),
657                item_official("core_pkg", "core"),
658                item_aur("high_pop", Some(10.0)),
659                item_official("extra_pkg", "extra"),
660            ],
661            sort_mode: SortMode::RepoThenName,
662            ..Default::default()
663        };
664
665        // Initial sort - populates both caches
666        sort_results_preserve_selection(&mut app);
667        assert!(app.sort_cache_repo_name.is_some());
668        assert!(app.sort_cache_aur_popularity.is_some());
669        let repo_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
670
671        // Switch to AUR popularity - should use cache
672        app.sort_mode = SortMode::AurPopularityThenOfficial;
673        sort_results_preserve_selection(&mut app);
674        let _aur_order: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
675        // AUR packages should be first
676        assert!(matches!(app.results[0].source, Source::Aur));
677
678        // Switch back to repo - should use cache
679        app.sort_mode = SortMode::RepoThenName;
680        sort_results_preserve_selection(&mut app);
681        let repo_order_again: Vec<String> = app.results.iter().map(|p| p.name.clone()).collect();
682        assert_eq!(repo_order, repo_order_again);
683    }
684}