Skip to main content

pacsea/events/
utils.rs

1use crossterm::event::KeyEvent;
2use tokio::sync::mpsc;
3
4use crate::state::{AppState, PackageItem};
5use std::time::Instant;
6
7/// What: Check if a key event matches any chord in a list, handling Shift+char edge cases.
8///
9/// Inputs:
10/// - `ke`: Key event from terminal
11/// - `list`: List of configured key chords to match against
12///
13/// Output:
14/// - `true` if the key event matches any chord in the list, `false` otherwise
15///
16/// Details:
17/// - Treats Shift+<char> from config as equivalent to uppercase char without Shift from terminal.
18/// - Handles cases where terminals report Shift inconsistently.
19#[must_use]
20pub fn matches_any(ke: &KeyEvent, list: &[crate::theme::KeyChord]) -> bool {
21    list.iter().any(|c| {
22        if (c.code, c.mods) == (ke.code, ke.modifiers) {
23            return true;
24        }
25        match (c.code, ke.code) {
26            (crossterm::event::KeyCode::Char(cfg_ch), crossterm::event::KeyCode::Char(ev_ch)) => {
27                let cfg_has_shift = c.mods.contains(crossterm::event::KeyModifiers::SHIFT);
28                if !cfg_has_shift {
29                    return false;
30                }
31                // Accept uppercase event regardless of SHIFT flag
32                if ev_ch == cfg_ch.to_ascii_uppercase() {
33                    return true;
34                }
35                // Accept lowercase char if terminal reports SHIFT in modifiers
36                if ke.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
37                    && ev_ch.to_ascii_lowercase() == cfg_ch
38                {
39                    return true;
40                }
41                false
42            }
43            _ => false,
44        }
45    })
46}
47
48/// What: Return the number of Unicode scalar values (characters) in the input.
49///
50/// Input: `s` string to measure
51/// Output: Character count as `usize`
52///
53/// Details: Counts Unicode scalar values using `s.chars().count()`.
54#[must_use]
55pub fn char_count(s: &str) -> usize {
56    s.chars().count()
57}
58
59/// What: Convert a character index to a byte index for slicing.
60///
61/// Input: `s` source string; `ci` character index
62/// Output: Byte index into `s` corresponding to `ci`
63///
64/// Details: Returns 0 for `ci==0`; returns `s.len()` when `ci>=char_count(s)`; otherwise maps
65/// the character index to a byte offset via `char_indices()`.
66#[must_use]
67pub fn byte_index_for_char(s: &str, ci: usize) -> usize {
68    let cc = char_count(s);
69    if ci == 0 {
70        return 0;
71    }
72    if ci >= cc {
73        return s.len();
74    }
75    s.char_indices()
76        .map(|(i, _)| i)
77        .nth(ci)
78        .map_or(s.len(), |i| i)
79}
80
81/// What: Advance selection in the Recent pane to the next/previous match of the pane-find pattern.
82///
83/// Input: `app` mutable application state; `forward` when true searches downward, else upward
84/// Output: No return value; updates `history_state` selection when a match is found
85///
86/// Details: Searches within the filtered Recent indices and wraps around the list; matching is
87/// case-insensitive against the current pane-find pattern.
88pub fn find_in_recent(app: &mut AppState, forward: bool) {
89    let Some(pattern) = app.pane_find.clone() else {
90        return;
91    };
92    let inds = crate::ui::helpers::filtered_recent_indices(app);
93    if inds.is_empty() {
94        return;
95    }
96    let start = app.history_state.selected().unwrap_or(0);
97    let mut vi = start;
98    let n = inds.len();
99    for _ in 0..n {
100        vi = if forward {
101            (vi + 1) % n
102        } else if vi == 0 {
103            n - 1
104        } else {
105            vi - 1
106        };
107        let i = inds[vi];
108        if let Some(s) = app.recent_value_at(i)
109            && s.to_lowercase().contains(&pattern.to_lowercase())
110        {
111            app.history_state.select(Some(vi));
112            break;
113        }
114    }
115}
116
117/// What: Advance selection in the Install pane to the next/previous item matching the pane-find pattern.
118///
119/// Input: `app` mutable application state; `forward` when true searches downward, else upward
120/// Output: No return value; updates `install_state` selection when a match is found
121///
122/// Details: Operates on visible indices and tests case-insensitive matches against package name
123/// or description; wraps around the list.
124pub fn find_in_install(app: &mut AppState, forward: bool) {
125    let Some(pattern) = app.pane_find.clone() else {
126        return;
127    };
128    let inds = crate::ui::helpers::filtered_install_indices(app);
129    if inds.is_empty() {
130        return;
131    }
132    let start = app.install_state.selected().unwrap_or(0);
133    let mut vi = start;
134    let n = inds.len();
135    for _ in 0..n {
136        vi = if forward {
137            (vi + 1) % n
138        } else if vi == 0 {
139            n - 1
140        } else {
141            vi - 1
142        };
143        let i = inds[vi];
144        if let Some(p) = app.install_list.get(i)
145            && (p.name.to_lowercase().contains(&pattern.to_lowercase())
146                || p.description
147                    .to_lowercase()
148                    .contains(&pattern.to_lowercase()))
149        {
150            app.install_state.select(Some(vi));
151            break;
152        }
153    }
154}
155
156/// What: Ensure details reflect the currently selected result.
157///
158/// Input: `app` mutable application state; `details_tx` channel for details requests
159/// Output: No return value; uses cache or sends a details request
160///
161/// Details: If details for the selected item exist in the cache, they are applied immediately;
162/// otherwise, the item is sent over `details_tx` to be fetched asynchronously.
163pub fn refresh_selected_details(
164    app: &mut AppState,
165    details_tx: &mpsc::UnboundedSender<PackageItem>,
166) {
167    if let Some(item) = app.results.get(app.selected).cloned() {
168        // Reset scroll when package changes
169        app.details_scroll = 0;
170        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
171            app.details = cached;
172        } else {
173            let _ = details_tx.send(item);
174        }
175        queue_selected_aur_vote_state_check(app);
176    }
177}
178
179/// What: Queue a live AUR vote-state check for the currently selected result.
180///
181/// Inputs:
182/// - `app`: Mutable application state with current results selection.
183///
184/// Output:
185/// - None (updates vote-state cache and pending request fields).
186///
187/// Details:
188/// - Only queues checks for selected AUR packages when AUR voting is enabled.
189/// - Marks selected package as `Loading` and stores a single pending request.
190/// - Replaces an older pending request when selection changes rapidly.
191pub fn queue_selected_aur_vote_state_check(app: &mut AppState) {
192    let settings = crate::theme::settings();
193    if !settings.aur_vote_enabled {
194        return;
195    }
196    if !app.aur_vote_state_lookup_supported {
197        return;
198    }
199    let Some(item) = app.results.get(app.selected) else {
200        return;
201    };
202    if !matches!(item.source, crate::state::Source::Aur) {
203        return;
204    }
205
206    let pkgbase = item.name.clone();
207    if let Some(previous) = app.pending_aur_vote_state_request.replace(pkgbase.clone())
208        && previous != pkgbase
209        && matches!(
210            app.aur_vote_state_by_pkgbase.get(&previous),
211            Some(crate::state::app_state::AurVoteStateUi::Loading)
212        )
213    {
214        app.aur_vote_state_by_pkgbase
215            .insert(previous, crate::state::app_state::AurVoteStateUi::Unknown);
216    }
217    let should_mark_loading = !matches!(
218        app.aur_vote_state_by_pkgbase.get(&pkgbase),
219        Some(
220            crate::state::app_state::AurVoteStateUi::Voted
221                | crate::state::app_state::AurVoteStateUi::NotVoted
222        )
223    );
224    if should_mark_loading {
225        app.aur_vote_state_by_pkgbase
226            .insert(pkgbase, crate::state::app_state::AurVoteStateUi::Loading);
227    }
228}
229
230/// What: Move selection and queue live AUR vote-state check for selected package.
231///
232/// Inputs:
233/// - `app`: Mutable application state.
234/// - `delta`: Signed selection movement.
235/// - `details_tx`: Channel for async details requests.
236/// - `comments_tx`: Channel for async AUR comments requests.
237///
238/// Output:
239/// - None (mutates selection/details state and queues optional vote-state check).
240///
241/// Details:
242/// - Uses existing `logic::move_sel_cached` for selection/details coordination.
243/// - Then schedules live vote-state check for selected AUR package.
244pub fn move_sel_cached_with_vote_state(
245    app: &mut AppState,
246    delta: isize,
247    details_tx: &mpsc::UnboundedSender<PackageItem>,
248    comments_tx: &mpsc::UnboundedSender<String>,
249) {
250    crate::logic::move_sel_cached(app, delta, details_tx, comments_tx);
251    queue_selected_aur_vote_state_check(app);
252}
253
254/// Move news selection by delta, keeping it in view.
255pub fn move_news_selection(app: &mut AppState, delta: isize) {
256    if app.news_results.is_empty() {
257        app.news_selected = 0;
258        app.news_list_state.select(None);
259        app.details.url.clear();
260        return;
261    }
262    let len = app.news_results.len();
263    if app.news_selected >= len {
264        app.news_selected = len.saturating_sub(1);
265    }
266    app.news_list_state.select(Some(app.news_selected));
267    let steps = delta.unsigned_abs();
268    for _ in 0..steps {
269        if delta.is_negative() {
270            app.news_list_state.select_previous();
271        } else {
272            app.news_list_state.select_next();
273        }
274    }
275    let sel = app.news_list_state.selected().unwrap_or(0);
276    app.news_selected = std::cmp::min(sel, len.saturating_sub(1));
277    app.news_list_state.select(Some(app.news_selected));
278    update_news_url(app);
279}
280
281/// What: Compute updates-modal scroll offset that vertically centers the selected entry.
282///
283/// Inputs:
284/// - `entry_line_starts`: Mapping from entry index to first rendered line in wrapped output.
285/// - `total_lines`: Total rendered line count across wrapped updates rows.
286/// - `content_rect`: Optional updates content rectangle tuple `(x, y, width, height)`.
287/// - `selected`: Selected entry index.
288/// - `total_items`: Number of entries in the updates list.
289/// - `_current_scroll`: Reserved for callers; scroll is derived from selection and geometry only.
290///
291/// Output:
292/// - Returns the clamped scroll offset as `u16`.
293///
294/// Details:
295/// - Derives `visible_lines` from `content_rect` height and falls back to `1` when absent.
296/// - Uses rendered-line mapping to support wrapped rows consistently.
297/// - Centers the first rendered line of the selected entry (`⌊visible_lines / 2⌋` rows above it)
298///   when possible; clamps to `[0, max_scroll]` near list ends so the list never overscrolls.
299#[must_use]
300pub fn compute_updates_modal_scroll_for_selection(
301    entry_line_starts: &[u16],
302    total_lines: u16,
303    content_rect: Option<(u16, u16, u16, u16)>,
304    selected: usize,
305    total_items: usize,
306    _current_scroll: u16,
307) -> u16 {
308    let selected_line = entry_line_starts
309        .get(selected)
310        .copied()
311        .unwrap_or_else(|| u16::try_from(selected).unwrap_or(u16::MAX));
312    let visible_lines = content_rect.map_or(1, |(_, _, _, h)| h.max(1));
313    let half = visible_lines / 2;
314    let ideal = selected_line.saturating_sub(half);
315
316    let fallback_total = u16::try_from(total_items).unwrap_or(u16::MAX);
317    let max_scroll = total_lines
318        .max(fallback_total)
319        .saturating_sub(visible_lines);
320    ideal.min(max_scroll)
321}
322
323/// What: Compute visible updates indices for a slash-filter query.
324///
325/// Inputs:
326/// - `entries`: Full updates entries (`name`, `old_version`, `new_version`).
327/// - `query`: Filter query string entered in Updates modal.
328///
329/// Output:
330/// - Stable vector of original-entry indices that match query order.
331///
332/// Details:
333/// - Empty/whitespace query returns all entries.
334/// - Matching is fuzzy + case-insensitive against package name and source label.
335/// - Source labels are lowercase: `pacman` for official packages and `aur` for AUR packages.
336#[must_use]
337pub fn compute_updates_filtered_indices(
338    entries: &[(String, String, String)],
339    query: &str,
340) -> Vec<usize> {
341    let normalized = query.trim();
342    if normalized.is_empty() {
343        return (0..entries.len()).collect();
344    }
345
346    let query_lower = normalized.to_lowercase();
347
348    entries
349        .iter()
350        .enumerate()
351        .filter_map(|(idx, (name, _, _))| {
352            let source_label = if crate::index::find_package_by_name(name).is_some() {
353                "pacman"
354            } else {
355                "aur"
356            };
357            let name_lower = name.to_lowercase();
358            let matches_name = crate::util::fuzzy_match_rank(&name_lower, &query_lower).is_some();
359            let matches_source =
360                crate::util::fuzzy_match_rank(source_label, &query_lower).is_some();
361            if matches_name || matches_source {
362                Some(idx)
363            } else {
364                None
365            }
366        })
367        .collect()
368}
369
370/// Synchronize details URL and content with currently selected news item.
371/// Also triggers content fetching if channel is provided and content is not cached.
372pub fn update_news_url(app: &mut AppState) {
373    if let Some(item) = app.news_results.get(app.news_selected)
374        && let Some(url) = &item.url
375    {
376        app.details.url.clone_from(url);
377        // Check if content is cached
378        let mut cached = app.news_content_cache.get(url).cloned();
379        if let Some(ref c) = cached
380            && url.contains("://archlinux.org/packages/")
381            && !c.starts_with("Package Info:")
382        {
383            // Cached pre-metadata version: force refresh
384            cached = None;
385            tracing::debug!(
386                url,
387                "news content cache missing package metadata; will refetch"
388            );
389        }
390        app.news_content = cached;
391        if app.news_content.is_some() {
392            tracing::debug!(url, "news content served from cache");
393        } else {
394            // Content not cached - set debounce timer to wait 0.5 seconds before fetching
395            app.news_content_debounce_timer = Some(std::time::Instant::now());
396            tracing::debug!(url, "news content not cached, setting debounce timer");
397        }
398        app.news_content_scroll = 0;
399    } else {
400        app.details.url.clear();
401        app.news_content = None;
402        app.news_content_debounce_timer = None;
403    }
404    app.news_content_loading = false;
405}
406
407/// Request news content fetch if not cached or loading.
408/// Implements 0.5 second debounce - only requests after user stays on item for 0.5 seconds.
409pub fn maybe_request_news_content(
410    app: &mut AppState,
411    news_content_req_tx: &mpsc::UnboundedSender<String>,
412) {
413    // Only request if in news mode with a selected item that has a URL
414    if !matches!(app.app_mode, crate::state::types::AppMode::News) {
415        tracing::trace!("news_content: skip request, not in news mode");
416        return;
417    }
418    if app.news_content_loading {
419        tracing::debug!(
420            selected = app.news_selected,
421            "news_content: skip request, already loading"
422        );
423        return;
424    }
425    if let Some(item) = app.news_results.get(app.news_selected)
426        && let Some(url) = &item.url
427        && app.news_content.is_none()
428        && !app.news_content_cache.contains_key(url)
429    {
430        // Check debounce timer - only request after 0.5 seconds of staying on the item
431        // 500ms balances user experience with server load: long enough to avoid excessive
432        // fetches during rapid navigation, short enough to feel responsive.
433        const DEBOUNCE_DELAY_MS: u64 = 500;
434        if let Some(timer) = app.news_content_debounce_timer {
435            // Safe to unwrap: elapsed will be small (well within u64)
436            #[allow(clippy::cast_possible_truncation)]
437            let elapsed = timer.elapsed().as_millis() as u64;
438            if elapsed < DEBOUNCE_DELAY_MS {
439                // Debounce not expired yet - wait longer
440                tracing::trace!(
441                    selected = app.news_selected,
442                    url,
443                    elapsed_ms = elapsed,
444                    remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
445                    "news_content: debounce timer not expired, waiting"
446                );
447                return;
448            }
449            // Debounce expired - clear timer and proceed with request
450            app.news_content_debounce_timer = None;
451        } else {
452            // No debounce timer set - this shouldn't happen, but set it now
453            app.news_content_debounce_timer = Some(std::time::Instant::now());
454            tracing::debug!(
455                selected = app.news_selected,
456                url,
457                "news_content: no debounce timer, setting one now"
458            );
459            return;
460        }
461
462        app.news_content_loading = true;
463        app.news_content_loading_since = Some(Instant::now());
464        tracing::debug!(
465            selected = app.news_selected,
466            title = item.title,
467            url,
468            "news_content: requesting article content (debounce expired)"
469        );
470        if let Err(e) = news_content_req_tx.send(url.clone()) {
471            tracing::warn!(
472                error = %e,
473                selected = app.news_selected,
474                title = item.title,
475                url,
476                "news_content: failed to enqueue content request"
477            );
478            app.news_content_loading = false;
479            app.news_content_loading_since = None;
480            app.news_content = Some(format!("Failed to load content: {e}"));
481            app.toast_message = Some("News content request failed".to_string());
482            app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
483        }
484    } else {
485        tracing::trace!(
486            selected = app.news_selected,
487            has_item = app.news_results.get(app.news_selected).is_some(),
488            has_url = app
489                .news_results
490                .get(app.news_selected)
491                .and_then(|it| it.url.as_ref())
492                .is_some(),
493            content_cached = app
494                .news_results
495                .get(app.news_selected)
496                .and_then(|it| it.url.as_ref())
497                .is_some_and(|u| app.news_content_cache.contains_key(u)),
498            has_content = app.news_content.is_some(),
499            "news_content: skip request (cached/absent URL/already loaded)"
500        );
501    }
502}
503
504/// What: Ensure details reflect the selected item in the Install pane.
505///
506/// Input: `app` mutable application state; `details_tx` channel for details requests
507/// Output: No return value; focuses details on the selected Install item and uses cache or requests fetch
508///
509/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
510/// cache when present; otherwise sends a request over `details_tx`.
511pub fn refresh_install_details(
512    app: &mut AppState,
513    details_tx: &mpsc::UnboundedSender<PackageItem>,
514) {
515    let Some(vsel) = app.install_state.selected() else {
516        return;
517    };
518    let inds = crate::ui::helpers::filtered_install_indices(app);
519    if inds.is_empty() || vsel >= inds.len() {
520        return;
521    }
522    let i = inds[vsel];
523    if let Some(item) = app.install_list.get(i).cloned() {
524        // Reset scroll when package changes
525        app.details_scroll = 0;
526        // Focus details on the install selection
527        app.details_focus = Some(item.name.clone());
528
529        // Provide an immediate placeholder reflecting the selection
530        app.details.name.clone_from(&item.name);
531        app.details.version.clone_from(&item.version);
532        app.details.description.clear();
533        match &item.source {
534            crate::state::Source::Official { repo, arch } => {
535                app.details.repository.clone_from(repo);
536                app.details.architecture.clone_from(arch);
537            }
538            crate::state::Source::Aur => {
539                app.details.repository = "AUR".to_string();
540                app.details.architecture = "any".to_string();
541            }
542        }
543
544        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
545            app.details = cached;
546        } else {
547            let _ = details_tx.send(item);
548        }
549    }
550}
551
552/// What: Ensure details reflect the selected item in the Remove pane.
553///
554/// Input: `app` mutable application state; `details_tx` channel for details requests
555/// Output: No return value; focuses details on the selected Remove item and uses cache or requests fetch
556///
557/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
558/// cache when present; otherwise sends a request over `details_tx`.
559pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
560    let Some(vsel) = app.remove_state.selected() else {
561        return;
562    };
563    if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
564        return;
565    }
566    if let Some(item) = app.remove_list.get(vsel).cloned() {
567        // Reset scroll when package changes
568        app.details_scroll = 0;
569        app.details_focus = Some(item.name.clone());
570        app.details.name.clone_from(&item.name);
571        app.details.version.clone_from(&item.version);
572        app.details.description.clear();
573        match &item.source {
574            crate::state::Source::Official { repo, arch } => {
575                app.details.repository.clone_from(repo);
576                app.details.architecture.clone_from(arch);
577            }
578            crate::state::Source::Aur => {
579                app.details.repository = "AUR".to_string();
580                app.details.architecture = "any".to_string();
581            }
582        }
583        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
584            app.details = cached;
585        } else {
586            let _ = details_tx.send(item);
587        }
588    }
589}
590
591/// What: Ensure details reflect the selected item in the Downgrade pane.
592///
593/// Input: `app` mutable application state; `details_tx` channel for details requests
594/// Output: No return value; focuses details on the selected Downgrade item and uses cache or requests fetch
595///
596/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
597/// cache when present; otherwise sends a request over `details_tx`.
598pub fn refresh_downgrade_details(
599    app: &mut AppState,
600    details_tx: &mpsc::UnboundedSender<PackageItem>,
601) {
602    let Some(vsel) = app.downgrade_state.selected() else {
603        return;
604    };
605    if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
606        return;
607    }
608    if let Some(item) = app.downgrade_list.get(vsel).cloned() {
609        // Reset scroll when package changes
610        app.details_scroll = 0;
611        app.details_focus = Some(item.name.clone());
612        app.details.name.clone_from(&item.name);
613        app.details.version.clone_from(&item.version);
614        app.details.description.clear();
615        match &item.source {
616            crate::state::Source::Official { repo, arch } => {
617                app.details.repository.clone_from(repo);
618                app.details.architecture.clone_from(arch);
619            }
620            crate::state::Source::Aur => {
621                app.details.repository = "AUR".to_string();
622                app.details.architecture = "any".to_string();
623            }
624        }
625        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
626            app.details = cached;
627        } else {
628            let _ = details_tx.send(item);
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    /// What: Produce a baseline `AppState` tailored for utils tests.
638    ///
639    /// Inputs:
640    /// - None; relies on `Default::default()` for deterministic state.
641    ///
642    /// Output:
643    /// - Fresh `AppState` instance for individual unit tests.
644    ///
645    /// Details:
646    /// - Centralizes setup so each test starts from a clean copy without repeated boilerplate.
647    fn new_app() -> AppState {
648        AppState::default()
649    }
650
651    #[test]
652    /// What: Ensure `char_count` returns the number of Unicode scalar values.
653    ///
654    /// Inputs:
655    /// - Strings `"abc"`, `"π"`, and `"aπb"`.
656    ///
657    /// Output:
658    /// - Counts `3`, `1`, and `3` respectively.
659    ///
660    /// Details:
661    /// - Demonstrates correct handling of multi-byte characters.
662    fn char_count_basic() {
663        assert_eq!(char_count("abc"), 3);
664        assert_eq!(char_count("π"), 1);
665        assert_eq!(char_count("aπb"), 3);
666    }
667
668    #[test]
669    /// What: Verify `byte_index_for_char` translates character indices to UTF-8 byte offsets.
670    ///
671    /// Inputs:
672    /// - String `"aπb"` with char indices 0 through 3.
673    ///
674    /// Output:
675    /// - Returns byte offsets `0`, `1`, `3`, and `len`.
676    ///
677    /// Details:
678    /// - Confirms the function respects variable-width encoding.
679    fn byte_index_for_char_basic() {
680        let s = "aπb";
681        assert_eq!(byte_index_for_char(s, 0), 0);
682        assert_eq!(byte_index_for_char(s, 1), 1);
683        assert_eq!(byte_index_for_char(s, 2), 1 + "π".len());
684        assert_eq!(byte_index_for_char(s, 3), s.len());
685    }
686
687    #[test]
688    /// What: Ensure `find_in_recent` cycles through entries matching the pane filter.
689    ///
690    /// Inputs:
691    /// - Recent list `alpha`, `beta`, `gamma` with filter `"a"`.
692    ///
693    /// Output:
694    /// - Selection rotates among matching entries without panicking.
695    ///
696    /// Details:
697    /// - Provides smoke coverage for the wrap-around logic inside the helper.
698    fn find_in_recent_basic() {
699        let mut app = new_app();
700        app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
701        app.pane_find = Some("a".into());
702        app.history_state.select(Some(0));
703        find_in_recent(&mut app, true);
704        assert!(app.history_state.selected().is_some());
705    }
706
707    #[test]
708    /// What: Check `find_in_install` advances selection to the next matching entry by name or description.
709    ///
710    /// Inputs:
711    /// - Install list with `ripgrep` and `fd`, filter term `"rip"` while selection starts on the second item.
712    ///
713    /// Output:
714    /// - Selection wraps to the first item containing the filter term.
715    ///
716    /// Details:
717    /// - Protects against regressions in forward search and wrap-around behaviour.
718    fn find_in_install_basic() {
719        let mut app = new_app();
720        app.install_list = vec![
721            crate::state::PackageItem {
722                name: "ripgrep".into(),
723                version: "1".into(),
724                description: "fast search".into(),
725                source: crate::state::Source::Aur,
726                popularity: None,
727                out_of_date: None,
728                orphaned: false,
729            },
730            crate::state::PackageItem {
731                name: "fd".into(),
732                version: "1".into(),
733                description: "find".into(),
734                source: crate::state::Source::Aur,
735                popularity: None,
736                out_of_date: None,
737                orphaned: false,
738            },
739        ];
740        app.pane_find = Some("rip".into());
741        // Start from visible selection 1 so advancing wraps to 0 matching "ripgrep"
742        app.install_state.select(Some(1));
743        find_in_install(&mut app, true);
744        assert_eq!(app.install_state.selected(), Some(0));
745    }
746
747    #[test]
748    /// What: Ensure `refresh_selected_details` dispatches a fetch when cache misses occur.
749    ///
750    /// Inputs:
751    /// - Results list with a single entry and an empty details cache.
752    ///
753    /// Output:
754    /// - Sends the selected item through `details_tx`, confirming a fetch request.
755    ///
756    /// Details:
757    /// - Uses an unbounded channel to observe the request without performing actual I/O.
758    fn refresh_selected_details_requests_when_missing() {
759        let mut app = new_app();
760        app.results = vec![crate::state::PackageItem {
761            name: "rg".into(),
762            version: "1".into(),
763            description: String::new(),
764            source: crate::state::Source::Aur,
765            popularity: None,
766            out_of_date: None,
767            orphaned: false,
768        }];
769        app.selected = 0;
770        let (tx, mut rx) = mpsc::unbounded_channel();
771        refresh_selected_details(&mut app, &tx);
772        let got = rx.try_recv().ok();
773        assert!(got.is_some());
774    }
775
776    #[test]
777    /// What: Ensure vote-state checks are skipped when live lookup is unsupported.
778    ///
779    /// Inputs:
780    /// - A selected AUR package with cached `Voted` state and lookup support disabled.
781    ///
782    /// Output:
783    /// - No pending request is queued and cached state remains unchanged.
784    ///
785    /// Details:
786    /// - Prevents replacing persisted stable vote-state with transient loading state
787    ///   after the runtime detects unsupported `list-votes`.
788    fn queue_vote_state_check_skips_when_lookup_unsupported() {
789        let mut app = new_app();
790        app.results = vec![crate::state::PackageItem {
791            name: "pacsea-bin".into(),
792            version: "1".into(),
793            description: String::new(),
794            source: crate::state::Source::Aur,
795            popularity: None,
796            out_of_date: None,
797            orphaned: false,
798        }];
799        app.selected = 0;
800        app.aur_vote_state_lookup_supported = false;
801        app.aur_vote_state_by_pkgbase.insert(
802            "pacsea-bin".into(),
803            crate::state::app_state::AurVoteStateUi::Voted,
804        );
805
806        queue_selected_aur_vote_state_check(&mut app);
807
808        assert!(app.pending_aur_vote_state_request.is_none());
809        assert!(matches!(
810            app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
811            Some(crate::state::app_state::AurVoteStateUi::Voted)
812        ));
813    }
814
815    #[test]
816    /// What: Ensure queuing live vote-state checks does not overwrite stable cached state.
817    ///
818    /// Inputs:
819    /// - Selected AUR package with existing `Voted` cache.
820    ///
821    /// Output:
822    /// - Request is queued, but cached state stays `Voted` instead of switching to `Loading`.
823    ///
824    /// Details:
825    /// - Prevents stable persisted state from disappearing during transient live checks.
826    fn queue_vote_state_check_preserves_stable_cached_state() {
827        let mut app = new_app();
828        app.results = vec![crate::state::PackageItem {
829            name: "pacsea-bin".into(),
830            version: "1".into(),
831            description: String::new(),
832            source: crate::state::Source::Aur,
833            popularity: None,
834            out_of_date: None,
835            orphaned: false,
836        }];
837        app.selected = 0;
838        app.aur_vote_state_by_pkgbase.insert(
839            "pacsea-bin".into(),
840            crate::state::app_state::AurVoteStateUi::Voted,
841        );
842
843        queue_selected_aur_vote_state_check(&mut app);
844
845        assert_eq!(
846            app.pending_aur_vote_state_request,
847            Some("pacsea-bin".to_string())
848        );
849        assert!(matches!(
850            app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
851            Some(crate::state::app_state::AurVoteStateUi::Voted)
852        ));
853    }
854
855    #[test]
856    /// What: Ensure missing updates content rect falls back to one visible line.
857    ///
858    /// Inputs:
859    /// - Wrapped line starts with no viewport rect and selection on later entry.
860    ///
861    /// Output:
862    /// - Scroll moves to selected line and remains clamped.
863    ///
864    /// Details:
865    /// - Guards deterministic behavior when geometry is unavailable.
866    fn updates_scroll_fallback_visible_lines_when_rect_missing() {
867        let scroll = compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, None, 1, 3, 0);
868        assert_eq!(scroll, 3);
869    }
870
871    #[test]
872    /// What: Ensure tiny viewport heights still keep selected wrapped line visible.
873    ///
874    /// Inputs:
875    /// - Height-1 and height-2 content rects with later selected entries.
876    ///
877    /// Output:
878    /// - Scroll adjusts forward without overshooting bounds.
879    ///
880    /// Details:
881    /// - Prevents regressions in very small terminal layouts.
882    fn updates_scroll_handles_tiny_viewport_heights() {
883        let height_one = Some((0, 0, 40, 1));
884        let scroll_one =
885            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_one, 1, 3, 0);
886        assert_eq!(scroll_one, 3);
887
888        let height_two = Some((0, 0, 40, 2));
889        let scroll_two =
890            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_two, 2, 3, 0);
891        assert_eq!(scroll_two, 4);
892    }
893
894    #[test]
895    /// What: Ensure updates modal scroll centers the selected rendered line when space allows.
896    ///
897    /// Inputs:
898    /// - Ten logical lines, viewport height five, selection on line four (entry index 2).
899    ///
900    /// Output:
901    /// - Scroll offset two so line four sits in the middle row of the viewport.
902    ///
903    /// Details:
904    /// - Complements edge-clamp tests by checking the common middle-of-list case.
905    fn updates_scroll_centers_selection_in_viewport() {
906        let starts: Vec<u16> = (0..10).collect();
907        let rect = Some((0, 0, 40, 5));
908        let scroll = compute_updates_modal_scroll_for_selection(&starts, 10, rect, 4, 10, 0);
909        assert_eq!(scroll, 2);
910    }
911
912    #[test]
913    /// What: Ensure large viewport clamps updates modal scroll to top.
914    ///
915    /// Inputs:
916    /// - Viewport height greater than total rendered lines.
917    ///
918    /// Output:
919    /// - Scroll returns to zero.
920    ///
921    /// Details:
922    /// - Confirms no overscroll when all rows fit on screen.
923    fn updates_scroll_clamps_to_zero_when_viewport_exceeds_total() {
924        let large_rect = Some((0, 0, 40, 20));
925        let scroll =
926            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, large_rect, 2, 3, 10);
927        assert_eq!(scroll, 0);
928    }
929
930    #[test]
931    /// What: Ensure updates filter returns all indices for empty query.
932    ///
933    /// Inputs:
934    /// - Three updates entries and an empty query.
935    ///
936    /// Output:
937    /// - Returns all original entry indices in stable order.
938    ///
939    /// Details:
940    /// - Guards no-op filter behavior when slash mode is entered/cleared.
941    fn updates_filter_returns_all_indices_for_empty_query() {
942        let entries = vec![
943            ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
944            ("fd".to_string(), "8".to_string(), "9".to_string()),
945            ("bat".to_string(), "1".to_string(), "2".to_string()),
946        ];
947        let indices = compute_updates_filtered_indices(&entries, "");
948        assert_eq!(indices, vec![0, 1, 2]);
949    }
950
951    #[test]
952    /// What: Ensure updates filter performs fuzzy case-insensitive package matching.
953    ///
954    /// Inputs:
955    /// - Entries containing "ripgrep" and query "RG".
956    ///
957    /// Output:
958    /// - Includes the "ripgrep" entry index.
959    ///
960    /// Details:
961    /// - Validates phase-4 matcher behavior for shorthand package queries.
962    fn updates_filter_matches_package_name_fuzzy_case_insensitive() {
963        let entries = vec![
964            ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
965            ("fd".to_string(), "8".to_string(), "9".to_string()),
966        ];
967        let indices = compute_updates_filtered_indices(&entries, "RG");
968        assert_eq!(indices, vec![0]);
969    }
970
971    #[test]
972    /// What: Ensure updates filter can match source labels.
973    ///
974    /// Inputs:
975    /// - Entries expected to include AUR rows and query "aur".
976    ///
977    /// Output:
978    /// - Every returned index maps to an AUR package.
979    ///
980    /// Details:
981    /// - Verifies source-label matching path used by slash filter.
982    fn updates_filter_matches_source_label() {
983        let entries = vec![
984            (
985                "pacsea-bin".to_string(),
986                "0.9".to_string(),
987                "1.0".to_string(),
988            ),
989            (
990                "pacsea-git".to_string(),
991                "0.9".to_string(),
992                "1.0".to_string(),
993            ),
994        ];
995        let indices = compute_updates_filtered_indices(&entries, "aur");
996        assert!(
997            !indices.is_empty(),
998            "expected at least one AUR package available in fixture"
999        );
1000        for idx in indices {
1001            let (name, _, _) = &entries[idx];
1002            assert!(crate::index::find_package_by_name(name).is_none());
1003        }
1004    }
1005}