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 keeps the selected entry visible.
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`: Existing scroll offset before adjustment.
290///
291/// Output:
292/// - Returns the next 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/// - Clamps output to valid range to prevent underflow/overscroll.
298#[must_use]
299pub fn compute_updates_modal_scroll_for_selection(
300    entry_line_starts: &[u16],
301    total_lines: u16,
302    content_rect: Option<(u16, u16, u16, u16)>,
303    selected: usize,
304    total_items: usize,
305    current_scroll: u16,
306) -> u16 {
307    let selected_line = entry_line_starts
308        .get(selected)
309        .copied()
310        .unwrap_or_else(|| u16::try_from(selected).unwrap_or(u16::MAX));
311    let visible_lines = content_rect.map_or(1, |(_, _, _, h)| h.max(1));
312    let mut scroll = current_scroll;
313
314    if selected_line < scroll {
315        scroll = selected_line;
316    } else if selected_line >= scroll.saturating_add(visible_lines) {
317        scroll = selected_line.saturating_sub(visible_lines.saturating_sub(1));
318    }
319
320    let fallback_total = u16::try_from(total_items).unwrap_or(u16::MAX);
321    let max_scroll = total_lines
322        .max(fallback_total)
323        .saturating_sub(visible_lines);
324    scroll.min(max_scroll)
325}
326
327/// What: Compute visible updates indices for a slash-filter query.
328///
329/// Inputs:
330/// - `entries`: Full updates entries (`name`, `old_version`, `new_version`).
331/// - `query`: Filter query string entered in Updates modal.
332///
333/// Output:
334/// - Stable vector of original-entry indices that match query order.
335///
336/// Details:
337/// - Empty/whitespace query returns all entries.
338/// - Matching is fuzzy + case-insensitive against package name and source label.
339/// - Source labels are lowercase: `pacman` for official packages and `aur` for AUR packages.
340#[must_use]
341pub fn compute_updates_filtered_indices(
342    entries: &[(String, String, String)],
343    query: &str,
344) -> Vec<usize> {
345    let normalized = query.trim();
346    if normalized.is_empty() {
347        return (0..entries.len()).collect();
348    }
349
350    let query_lower = normalized.to_lowercase();
351
352    entries
353        .iter()
354        .enumerate()
355        .filter_map(|(idx, (name, _, _))| {
356            let source_label = if crate::index::find_package_by_name(name).is_some() {
357                "pacman"
358            } else {
359                "aur"
360            };
361            let name_lower = name.to_lowercase();
362            let matches_name = crate::util::fuzzy_match_rank(&name_lower, &query_lower).is_some();
363            let matches_source =
364                crate::util::fuzzy_match_rank(source_label, &query_lower).is_some();
365            if matches_name || matches_source {
366                Some(idx)
367            } else {
368                None
369            }
370        })
371        .collect()
372}
373
374/// Synchronize details URL and content with currently selected news item.
375/// Also triggers content fetching if channel is provided and content is not cached.
376pub fn update_news_url(app: &mut AppState) {
377    if let Some(item) = app.news_results.get(app.news_selected)
378        && let Some(url) = &item.url
379    {
380        app.details.url.clone_from(url);
381        // Check if content is cached
382        let mut cached = app.news_content_cache.get(url).cloned();
383        if let Some(ref c) = cached
384            && url.contains("://archlinux.org/packages/")
385            && !c.starts_with("Package Info:")
386        {
387            // Cached pre-metadata version: force refresh
388            cached = None;
389            tracing::debug!(
390                url,
391                "news content cache missing package metadata; will refetch"
392            );
393        }
394        app.news_content = cached;
395        if app.news_content.is_some() {
396            tracing::debug!(url, "news content served from cache");
397        } else {
398            // Content not cached - set debounce timer to wait 0.5 seconds before fetching
399            app.news_content_debounce_timer = Some(std::time::Instant::now());
400            tracing::debug!(url, "news content not cached, setting debounce timer");
401        }
402        app.news_content_scroll = 0;
403    } else {
404        app.details.url.clear();
405        app.news_content = None;
406        app.news_content_debounce_timer = None;
407    }
408    app.news_content_loading = false;
409}
410
411/// Request news content fetch if not cached or loading.
412/// Implements 0.5 second debounce - only requests after user stays on item for 0.5 seconds.
413pub fn maybe_request_news_content(
414    app: &mut AppState,
415    news_content_req_tx: &mpsc::UnboundedSender<String>,
416) {
417    // Only request if in news mode with a selected item that has a URL
418    if !matches!(app.app_mode, crate::state::types::AppMode::News) {
419        tracing::trace!("news_content: skip request, not in news mode");
420        return;
421    }
422    if app.news_content_loading {
423        tracing::debug!(
424            selected = app.news_selected,
425            "news_content: skip request, already loading"
426        );
427        return;
428    }
429    if let Some(item) = app.news_results.get(app.news_selected)
430        && let Some(url) = &item.url
431        && app.news_content.is_none()
432        && !app.news_content_cache.contains_key(url)
433    {
434        // Check debounce timer - only request after 0.5 seconds of staying on the item
435        // 500ms balances user experience with server load: long enough to avoid excessive
436        // fetches during rapid navigation, short enough to feel responsive.
437        const DEBOUNCE_DELAY_MS: u64 = 500;
438        if let Some(timer) = app.news_content_debounce_timer {
439            // Safe to unwrap: elapsed will be small (well within u64)
440            #[allow(clippy::cast_possible_truncation)]
441            let elapsed = timer.elapsed().as_millis() as u64;
442            if elapsed < DEBOUNCE_DELAY_MS {
443                // Debounce not expired yet - wait longer
444                tracing::trace!(
445                    selected = app.news_selected,
446                    url,
447                    elapsed_ms = elapsed,
448                    remaining_ms = DEBOUNCE_DELAY_MS - elapsed,
449                    "news_content: debounce timer not expired, waiting"
450                );
451                return;
452            }
453            // Debounce expired - clear timer and proceed with request
454            app.news_content_debounce_timer = None;
455        } else {
456            // No debounce timer set - this shouldn't happen, but set it now
457            app.news_content_debounce_timer = Some(std::time::Instant::now());
458            tracing::debug!(
459                selected = app.news_selected,
460                url,
461                "news_content: no debounce timer, setting one now"
462            );
463            return;
464        }
465
466        app.news_content_loading = true;
467        app.news_content_loading_since = Some(Instant::now());
468        tracing::debug!(
469            selected = app.news_selected,
470            title = item.title,
471            url,
472            "news_content: requesting article content (debounce expired)"
473        );
474        if let Err(e) = news_content_req_tx.send(url.clone()) {
475            tracing::warn!(
476                error = %e,
477                selected = app.news_selected,
478                title = item.title,
479                url,
480                "news_content: failed to enqueue content request"
481            );
482            app.news_content_loading = false;
483            app.news_content_loading_since = None;
484            app.news_content = Some(format!("Failed to load content: {e}"));
485            app.toast_message = Some("News content request failed".to_string());
486            app.toast_expires_at = Some(Instant::now() + std::time::Duration::from_secs(3));
487        }
488    } else {
489        tracing::trace!(
490            selected = app.news_selected,
491            has_item = app.news_results.get(app.news_selected).is_some(),
492            has_url = app
493                .news_results
494                .get(app.news_selected)
495                .and_then(|it| it.url.as_ref())
496                .is_some(),
497            content_cached = app
498                .news_results
499                .get(app.news_selected)
500                .and_then(|it| it.url.as_ref())
501                .is_some_and(|u| app.news_content_cache.contains_key(u)),
502            has_content = app.news_content.is_some(),
503            "news_content: skip request (cached/absent URL/already loaded)"
504        );
505    }
506}
507
508/// What: Ensure details reflect the selected item in the Install pane.
509///
510/// Input: `app` mutable application state; `details_tx` channel for details requests
511/// Output: No return value; focuses details on the selected Install item and uses cache or requests fetch
512///
513/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
514/// cache when present; otherwise sends a request over `details_tx`.
515pub fn refresh_install_details(
516    app: &mut AppState,
517    details_tx: &mpsc::UnboundedSender<PackageItem>,
518) {
519    let Some(vsel) = app.install_state.selected() else {
520        return;
521    };
522    let inds = crate::ui::helpers::filtered_install_indices(app);
523    if inds.is_empty() || vsel >= inds.len() {
524        return;
525    }
526    let i = inds[vsel];
527    if let Some(item) = app.install_list.get(i).cloned() {
528        // Reset scroll when package changes
529        app.details_scroll = 0;
530        // Focus details on the install selection
531        app.details_focus = Some(item.name.clone());
532
533        // Provide an immediate placeholder reflecting the selection
534        app.details.name.clone_from(&item.name);
535        app.details.version.clone_from(&item.version);
536        app.details.description.clear();
537        match &item.source {
538            crate::state::Source::Official { repo, arch } => {
539                app.details.repository.clone_from(repo);
540                app.details.architecture.clone_from(arch);
541            }
542            crate::state::Source::Aur => {
543                app.details.repository = "AUR".to_string();
544                app.details.architecture = "any".to_string();
545            }
546        }
547
548        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
549            app.details = cached;
550        } else {
551            let _ = details_tx.send(item);
552        }
553    }
554}
555
556/// What: Ensure details reflect the selected item in the Remove pane.
557///
558/// Input: `app` mutable application state; `details_tx` channel for details requests
559/// Output: No return value; focuses details on the selected Remove item and uses cache or requests fetch
560///
561/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
562/// cache when present; otherwise sends a request over `details_tx`.
563pub fn refresh_remove_details(app: &mut AppState, details_tx: &mpsc::UnboundedSender<PackageItem>) {
564    let Some(vsel) = app.remove_state.selected() else {
565        return;
566    };
567    if app.remove_list.is_empty() || vsel >= app.remove_list.len() {
568        return;
569    }
570    if let Some(item) = app.remove_list.get(vsel).cloned() {
571        // Reset scroll when package changes
572        app.details_scroll = 0;
573        app.details_focus = Some(item.name.clone());
574        app.details.name.clone_from(&item.name);
575        app.details.version.clone_from(&item.version);
576        app.details.description.clear();
577        match &item.source {
578            crate::state::Source::Official { repo, arch } => {
579                app.details.repository.clone_from(repo);
580                app.details.architecture.clone_from(arch);
581            }
582            crate::state::Source::Aur => {
583                app.details.repository = "AUR".to_string();
584                app.details.architecture = "any".to_string();
585            }
586        }
587        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
588            app.details = cached;
589        } else {
590            let _ = details_tx.send(item);
591        }
592    }
593}
594
595/// What: Ensure details reflect the selected item in the Downgrade pane.
596///
597/// Input: `app` mutable application state; `details_tx` channel for details requests
598/// Output: No return value; focuses details on the selected Downgrade item and uses cache or requests fetch
599///
600/// Details: Sets `details_focus`, populates a placeholder from the selected item, then uses the
601/// cache when present; otherwise sends a request over `details_tx`.
602pub fn refresh_downgrade_details(
603    app: &mut AppState,
604    details_tx: &mpsc::UnboundedSender<PackageItem>,
605) {
606    let Some(vsel) = app.downgrade_state.selected() else {
607        return;
608    };
609    if app.downgrade_list.is_empty() || vsel >= app.downgrade_list.len() {
610        return;
611    }
612    if let Some(item) = app.downgrade_list.get(vsel).cloned() {
613        // Reset scroll when package changes
614        app.details_scroll = 0;
615        app.details_focus = Some(item.name.clone());
616        app.details.name.clone_from(&item.name);
617        app.details.version.clone_from(&item.version);
618        app.details.description.clear();
619        match &item.source {
620            crate::state::Source::Official { repo, arch } => {
621                app.details.repository.clone_from(repo);
622                app.details.architecture.clone_from(arch);
623            }
624            crate::state::Source::Aur => {
625                app.details.repository = "AUR".to_string();
626                app.details.architecture = "any".to_string();
627            }
628        }
629        if let Some(cached) = app.details_cache.get(&item.name).cloned() {
630            app.details = cached;
631        } else {
632            let _ = details_tx.send(item);
633        }
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    /// What: Produce a baseline `AppState` tailored for utils tests.
642    ///
643    /// Inputs:
644    /// - None; relies on `Default::default()` for deterministic state.
645    ///
646    /// Output:
647    /// - Fresh `AppState` instance for individual unit tests.
648    ///
649    /// Details:
650    /// - Centralizes setup so each test starts from a clean copy without repeated boilerplate.
651    fn new_app() -> AppState {
652        AppState::default()
653    }
654
655    #[test]
656    /// What: Ensure `char_count` returns the number of Unicode scalar values.
657    ///
658    /// Inputs:
659    /// - Strings `"abc"`, `"π"`, and `"aπb"`.
660    ///
661    /// Output:
662    /// - Counts `3`, `1`, and `3` respectively.
663    ///
664    /// Details:
665    /// - Demonstrates correct handling of multi-byte characters.
666    fn char_count_basic() {
667        assert_eq!(char_count("abc"), 3);
668        assert_eq!(char_count("π"), 1);
669        assert_eq!(char_count("aπb"), 3);
670    }
671
672    #[test]
673    /// What: Verify `byte_index_for_char` translates character indices to UTF-8 byte offsets.
674    ///
675    /// Inputs:
676    /// - String `"aπb"` with char indices 0 through 3.
677    ///
678    /// Output:
679    /// - Returns byte offsets `0`, `1`, `3`, and `len`.
680    ///
681    /// Details:
682    /// - Confirms the function respects variable-width encoding.
683    fn byte_index_for_char_basic() {
684        let s = "aπb";
685        assert_eq!(byte_index_for_char(s, 0), 0);
686        assert_eq!(byte_index_for_char(s, 1), 1);
687        assert_eq!(byte_index_for_char(s, 2), 1 + "π".len());
688        assert_eq!(byte_index_for_char(s, 3), s.len());
689    }
690
691    #[test]
692    /// What: Ensure `find_in_recent` cycles through entries matching the pane filter.
693    ///
694    /// Inputs:
695    /// - Recent list `alpha`, `beta`, `gamma` with filter `"a"`.
696    ///
697    /// Output:
698    /// - Selection rotates among matching entries without panicking.
699    ///
700    /// Details:
701    /// - Provides smoke coverage for the wrap-around logic inside the helper.
702    fn find_in_recent_basic() {
703        let mut app = new_app();
704        app.load_recent_items(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
705        app.pane_find = Some("a".into());
706        app.history_state.select(Some(0));
707        find_in_recent(&mut app, true);
708        assert!(app.history_state.selected().is_some());
709    }
710
711    #[test]
712    /// What: Check `find_in_install` advances selection to the next matching entry by name or description.
713    ///
714    /// Inputs:
715    /// - Install list with `ripgrep` and `fd`, filter term `"rip"` while selection starts on the second item.
716    ///
717    /// Output:
718    /// - Selection wraps to the first item containing the filter term.
719    ///
720    /// Details:
721    /// - Protects against regressions in forward search and wrap-around behaviour.
722    fn find_in_install_basic() {
723        let mut app = new_app();
724        app.install_list = vec![
725            crate::state::PackageItem {
726                name: "ripgrep".into(),
727                version: "1".into(),
728                description: "fast search".into(),
729                source: crate::state::Source::Aur,
730                popularity: None,
731                out_of_date: None,
732                orphaned: false,
733            },
734            crate::state::PackageItem {
735                name: "fd".into(),
736                version: "1".into(),
737                description: "find".into(),
738                source: crate::state::Source::Aur,
739                popularity: None,
740                out_of_date: None,
741                orphaned: false,
742            },
743        ];
744        app.pane_find = Some("rip".into());
745        // Start from visible selection 1 so advancing wraps to 0 matching "ripgrep"
746        app.install_state.select(Some(1));
747        find_in_install(&mut app, true);
748        assert_eq!(app.install_state.selected(), Some(0));
749    }
750
751    #[test]
752    /// What: Ensure `refresh_selected_details` dispatches a fetch when cache misses occur.
753    ///
754    /// Inputs:
755    /// - Results list with a single entry and an empty details cache.
756    ///
757    /// Output:
758    /// - Sends the selected item through `details_tx`, confirming a fetch request.
759    ///
760    /// Details:
761    /// - Uses an unbounded channel to observe the request without performing actual I/O.
762    fn refresh_selected_details_requests_when_missing() {
763        let mut app = new_app();
764        app.results = vec![crate::state::PackageItem {
765            name: "rg".into(),
766            version: "1".into(),
767            description: String::new(),
768            source: crate::state::Source::Aur,
769            popularity: None,
770            out_of_date: None,
771            orphaned: false,
772        }];
773        app.selected = 0;
774        let (tx, mut rx) = mpsc::unbounded_channel();
775        refresh_selected_details(&mut app, &tx);
776        let got = rx.try_recv().ok();
777        assert!(got.is_some());
778    }
779
780    #[test]
781    /// What: Ensure vote-state checks are skipped when live lookup is unsupported.
782    ///
783    /// Inputs:
784    /// - A selected AUR package with cached `Voted` state and lookup support disabled.
785    ///
786    /// Output:
787    /// - No pending request is queued and cached state remains unchanged.
788    ///
789    /// Details:
790    /// - Prevents replacing persisted stable vote-state with transient loading state
791    ///   after the runtime detects unsupported `list-votes`.
792    fn queue_vote_state_check_skips_when_lookup_unsupported() {
793        let mut app = new_app();
794        app.results = vec![crate::state::PackageItem {
795            name: "pacsea-bin".into(),
796            version: "1".into(),
797            description: String::new(),
798            source: crate::state::Source::Aur,
799            popularity: None,
800            out_of_date: None,
801            orphaned: false,
802        }];
803        app.selected = 0;
804        app.aur_vote_state_lookup_supported = false;
805        app.aur_vote_state_by_pkgbase.insert(
806            "pacsea-bin".into(),
807            crate::state::app_state::AurVoteStateUi::Voted,
808        );
809
810        queue_selected_aur_vote_state_check(&mut app);
811
812        assert!(app.pending_aur_vote_state_request.is_none());
813        assert!(matches!(
814            app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
815            Some(crate::state::app_state::AurVoteStateUi::Voted)
816        ));
817    }
818
819    #[test]
820    /// What: Ensure queuing live vote-state checks does not overwrite stable cached state.
821    ///
822    /// Inputs:
823    /// - Selected AUR package with existing `Voted` cache.
824    ///
825    /// Output:
826    /// - Request is queued, but cached state stays `Voted` instead of switching to `Loading`.
827    ///
828    /// Details:
829    /// - Prevents stable persisted state from disappearing during transient live checks.
830    fn queue_vote_state_check_preserves_stable_cached_state() {
831        let mut app = new_app();
832        app.results = vec![crate::state::PackageItem {
833            name: "pacsea-bin".into(),
834            version: "1".into(),
835            description: String::new(),
836            source: crate::state::Source::Aur,
837            popularity: None,
838            out_of_date: None,
839            orphaned: false,
840        }];
841        app.selected = 0;
842        app.aur_vote_state_by_pkgbase.insert(
843            "pacsea-bin".into(),
844            crate::state::app_state::AurVoteStateUi::Voted,
845        );
846
847        queue_selected_aur_vote_state_check(&mut app);
848
849        assert_eq!(
850            app.pending_aur_vote_state_request,
851            Some("pacsea-bin".to_string())
852        );
853        assert!(matches!(
854            app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
855            Some(crate::state::app_state::AurVoteStateUi::Voted)
856        ));
857    }
858
859    #[test]
860    /// What: Ensure missing updates content rect falls back to one visible line.
861    ///
862    /// Inputs:
863    /// - Wrapped line starts with no viewport rect and selection on later entry.
864    ///
865    /// Output:
866    /// - Scroll moves to selected line and remains clamped.
867    ///
868    /// Details:
869    /// - Guards deterministic behavior when geometry is unavailable.
870    fn updates_scroll_fallback_visible_lines_when_rect_missing() {
871        let scroll = compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, None, 1, 3, 0);
872        assert_eq!(scroll, 3);
873    }
874
875    #[test]
876    /// What: Ensure tiny viewport heights still keep selected wrapped line visible.
877    ///
878    /// Inputs:
879    /// - Height-1 and height-2 content rects with later selected entries.
880    ///
881    /// Output:
882    /// - Scroll adjusts forward without overshooting bounds.
883    ///
884    /// Details:
885    /// - Prevents regressions in very small terminal layouts.
886    fn updates_scroll_handles_tiny_viewport_heights() {
887        let height_one = Some((0, 0, 40, 1));
888        let scroll_one =
889            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_one, 1, 3, 0);
890        assert_eq!(scroll_one, 3);
891
892        let height_two = Some((0, 0, 40, 2));
893        let scroll_two =
894            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, height_two, 2, 3, 0);
895        assert_eq!(scroll_two, 4);
896    }
897
898    #[test]
899    /// What: Ensure large viewport clamps updates modal scroll to top.
900    ///
901    /// Inputs:
902    /// - Viewport height greater than total rendered lines.
903    ///
904    /// Output:
905    /// - Scroll returns to zero.
906    ///
907    /// Details:
908    /// - Confirms no overscroll when all rows fit on screen.
909    fn updates_scroll_clamps_to_zero_when_viewport_exceeds_total() {
910        let large_rect = Some((0, 0, 40, 20));
911        let scroll =
912            compute_updates_modal_scroll_for_selection(&[0, 3, 5], 7, large_rect, 2, 3, 10);
913        assert_eq!(scroll, 0);
914    }
915
916    #[test]
917    /// What: Ensure updates filter returns all indices for empty query.
918    ///
919    /// Inputs:
920    /// - Three updates entries and an empty query.
921    ///
922    /// Output:
923    /// - Returns all original entry indices in stable order.
924    ///
925    /// Details:
926    /// - Guards no-op filter behavior when slash mode is entered/cleared.
927    fn updates_filter_returns_all_indices_for_empty_query() {
928        let entries = vec![
929            ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
930            ("fd".to_string(), "8".to_string(), "9".to_string()),
931            ("bat".to_string(), "1".to_string(), "2".to_string()),
932        ];
933        let indices = compute_updates_filtered_indices(&entries, "");
934        assert_eq!(indices, vec![0, 1, 2]);
935    }
936
937    #[test]
938    /// What: Ensure updates filter performs fuzzy case-insensitive package matching.
939    ///
940    /// Inputs:
941    /// - Entries containing "ripgrep" and query "RG".
942    ///
943    /// Output:
944    /// - Includes the "ripgrep" entry index.
945    ///
946    /// Details:
947    /// - Validates phase-4 matcher behavior for shorthand package queries.
948    fn updates_filter_matches_package_name_fuzzy_case_insensitive() {
949        let entries = vec![
950            ("ripgrep".to_string(), "13".to_string(), "14".to_string()),
951            ("fd".to_string(), "8".to_string(), "9".to_string()),
952        ];
953        let indices = compute_updates_filtered_indices(&entries, "RG");
954        assert_eq!(indices, vec![0]);
955    }
956
957    #[test]
958    /// What: Ensure updates filter can match source labels.
959    ///
960    /// Inputs:
961    /// - Entries expected to include AUR rows and query "aur".
962    ///
963    /// Output:
964    /// - Every returned index maps to an AUR package.
965    ///
966    /// Details:
967    /// - Verifies source-label matching path used by slash filter.
968    fn updates_filter_matches_source_label() {
969        let entries = vec![
970            (
971                "pacsea-bin".to_string(),
972                "0.9".to_string(),
973                "1.0".to_string(),
974            ),
975            (
976                "pacsea-git".to_string(),
977                "0.9".to_string(),
978                "1.0".to_string(),
979            ),
980        ];
981        let indices = compute_updates_filtered_indices(&entries, "aur");
982        assert!(
983            !indices.is_empty(),
984            "expected at least one AUR package available in fixture"
985        );
986        for idx in indices {
987            let (name, _, _) = &entries[idx];
988            assert!(crate::index::find_package_by_name(name).is_none());
989        }
990    }
991}