#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use super::actions::Action;
use super::types::{DetailKind, DetailRef, PerTabState};
use crate::github::types::{
CheckState, Inbox, Issue, Label, MergeStateStatus, Mergeable, PullRequest, Review,
ReviewDecision, Role,
};
use crate::ui::pr_detail::DetailSection;
use chrono::Utc;
fn make_pr(repo: &str, flag_variant: &str, viewer: &str) -> PullRequest {
let mut pr = PullRequest {
number: 1,
title: "Test PR".to_owned(),
url: "https://github.com/o/r/pull/1".to_owned(),
repo: repo.to_owned(),
author: viewer.to_owned(),
is_draft: false,
mergeable: Mergeable::Mergeable,
merge_state: MergeStateStatus::Clean,
review_decision: None,
commits_count: 1,
comments_count: 0,
check_state: Some(CheckState::Success),
failing_checks: vec![],
unresolved_threads: 0,
requested_reviewers: vec![],
reviews: vec![],
updated_at: Utc::now(),
roles: vec![Role::Author],
base_ref: Some("main".to_owned()),
head_ref: Some("feat/test".to_owned()),
};
match flag_variant {
"conflict" => pr.mergeable = Mergeable::Conflicting,
"review_requested" => pr.requested_reviewers = vec![viewer.to_owned()],
"draft" => pr.is_draft = true,
"changes" => pr.review_decision = Some(ReviewDecision::ChangesRequested),
_ => {} }
pr
}
#[allow(dead_code)]
fn make_issue(repo: &str) -> Issue {
Issue {
number: 1,
title: "Test Issue".to_owned(),
url: "https://github.com/o/r/issues/1".to_owned(),
repo: repo.to_owned(),
author: "viewer".to_owned(),
comments_count: 0,
updated_at: Utc::now(),
labels: vec![Label { name: "bug".to_owned(), color: "ee0701".to_owned() }],
}
}
#[test]
fn on_inbox_loaded_sets_needs_action_count() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = Inbox {
viewer_login: "viewer".to_owned(),
prs: vec![
make_pr("o/r", "conflict", "viewer"), make_pr("o/r", "review_requested", "viewer"), make_pr("o/r", "draft", "viewer"), make_pr("o/r", "clean", "viewer"), make_pr("other/repo", "conflict", "viewer"), ],
issues: vec![],
};
app.on_inbox_loaded(inbox);
let tab = app.tabs.tabs.iter().find(|t| t.repo == "o/r").expect("tab for o/r");
assert_eq!(
tab.needs_action_count,
Some(2),
"Expected 2 action items in o/r, got {:?}",
tab.needs_action_count
);
}
#[test]
fn on_inbox_loaded_clears_error_and_fetching() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.fetching = true;
app.last_fetch_error = Some("prior error".to_owned());
let inbox = Inbox { viewer_login: "viewer".to_owned(), prs: vec![], issues: vec![] };
app.on_inbox_loaded(inbox);
assert!(!app.fetching);
assert!(app.last_fetch_error.is_none());
assert!(app.inbox_loaded_at.is_some());
}
#[test]
fn on_inbox_loaded_clamps_stale_selection() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.selection.insert("o/r".to_owned(), 4);
let inbox = Inbox {
viewer_login: "viewer".to_owned(),
prs: vec![make_pr("o/r", "clean", "viewer"), make_pr("o/r", "conflict", "viewer")],
issues: vec![],
};
app.on_inbox_loaded(inbox);
assert_eq!(app.selection.get("o/r"), Some(&1), "stale index 4 must clamp to len-1 = 1");
}
#[test]
fn on_inbox_loaded_clamps_empty_list() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.selection.insert("o/r".to_owned(), 3);
let inbox = Inbox { viewer_login: "viewer".to_owned(), prs: vec![], issues: vec![] };
app.on_inbox_loaded(inbox);
assert_eq!(app.selection.get("o/r"), Some(&0));
}
#[test]
fn on_fetch_failed_records_error() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.fetching = true;
app.on_fetch_failed("network timeout".to_owned());
assert!(!app.fetching);
assert_eq!(app.last_fetch_error.as_deref(), Some("network timeout"));
}
#[allow(dead_code)]
fn _use_types(_r: Review, _rd: ReviewDecision) {}
#[test]
fn esc_in_detail_focus_returns_to_dashboard() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
*app.scroll_mut(DetailSection::Description) = 42;
app.back_to_dashboard();
assert_eq!(app.focus, Focus::Dashboard);
assert!(app.pr_detail.is_none());
assert!(app.issue_detail.is_none());
assert!(app.detail_error.is_none());
assert!(app.pr_detail_scroll.is_empty(), "scroll map must be cleared on back_to_dashboard");
}
#[test]
fn enter_on_dashboard_populates_detail_fetching() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = Inbox {
viewer_login: "viewer".to_owned(),
prs: vec![make_pr("o/r", "clean", "viewer")],
issues: vec![],
};
app.on_inbox_loaded(inbox);
app.open_detail_for_selection();
assert_eq!(app.focus, Focus::Detail);
assert!(app.pr_detail_scroll.is_empty(), "scroll map should be empty after open");
}
#[test]
fn scroll_clamped_by_saturating_add() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
*app.scroll_mut(DetailSection::Description) = u16::MAX;
let current = app.scroll_for(DetailSection::Description);
*app.scroll_mut(DetailSection::Description) = current.saturating_add(1);
assert_eq!(
app.scroll_for(DetailSection::Description),
u16::MAX,
"saturating add must not wrap"
);
}
#[test]
fn open_browser_error_message_includes_url() {
use anyhow::Context as _;
let url = "https://example.invalid/pr/1";
let wrapped: anyhow::Result<()> = Err(anyhow::anyhow!("simulated launch failure"))
.with_context(|| format!("failed to open URL in browser: {url}"));
let msg = format!("{:#}", wrapped.unwrap_err());
assert!(msg.contains(url), "error message must include the URL for debuggability");
assert!(
msg.contains("failed to open URL in browser"),
"wrapper message must name the operation"
);
}
#[test]
fn open_browser_refuses_non_https_scheme() {
for hostile in ["file:///etc/passwd", "http://example.com", "ssh://bad", ""] {
let err = crate::actions_util::open_url_in_browser(hostile)
.expect_err("non-https URL must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("refusing to open non-https URL"),
"rejection message must name the guard, got: {msg}"
);
assert!(msg.contains(hostile), "rejection message must echo the URL");
}
}
#[test]
#[ignore = "clipboard unavailable on headless CI; run manually"]
fn copy_url_does_not_panic() {
let result = crate::actions_util::copy_to_clipboard("https://github.com");
let _ = result;
}
fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
#[test]
fn v_in_detail_enters_copy_mode_and_clamps_to_content() {
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
*app.scroll_mut(DetailSection::Description) = 12;
app.handle_key(key(crossterm::event::KeyCode::Char('v')));
assert!(app.copy_mode.active);
assert_eq!(
app.copy_mode.cursor.row, 0,
"cursor must clamp to last real row (0 when no content)"
);
assert_eq!(app.copy_mode.cursor.col, 0);
assert!(app.copy_mode.anchor.is_none(), "no selection until V pressed");
}
#[test]
fn esc_in_copy_mode_stays_in_detail() {
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.copy_mode.enter(0, 0);
app.handle_key(key(crossterm::event::KeyCode::Esc));
assert!(!app.copy_mode.active);
assert_eq!(app.focus, Focus::Detail, "Esc in copy mode must not leave detail");
}
#[test]
fn back_to_dashboard_clears_copy_mode() {
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.copy_mode.enter(5, 7);
app.back_to_dashboard();
assert_eq!(app.focus, Focus::Dashboard);
assert!(!app.copy_mode.active);
assert_eq!(app.copy_mode.cursor, crate::ui::copy_mode::Pos::default());
}
#[test]
fn mouse_wheel_scrolls_detail() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
use crossterm::event::{MouseEvent, MouseEventKind};
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(3, 2, 4, 2));
*app.scroll_mut(DetailSection::Description) = 0;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(28, 0, 80, 1));
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 40, row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert_eq!(app.scroll_for(DetailSection::Description), 3, "scroll down by 3");
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 40,
row: 5,
modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert_eq!(app.scroll_for(DetailSection::Description), 0, "scroll up by 3 returns to 0");
}
#[test]
fn mouse_click_in_detail_places_cursor() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(28, 1, 80, 20));
app.pr_detail_viewport.set(ratatui::layout::Rect::new(28, 1, 80, 20));
*app.scroll_mut(DetailSection::Description) = 5;
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 38, row: 3, modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert!(app.copy_mode.active);
assert_eq!(app.copy_mode.cursor.row, 7);
assert_eq!(app.copy_mode.cursor.col, 10);
}
#[test]
fn mouse_click_outside_viewport_is_ignored() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(28, 1, 10, 10));
app.pr_detail_viewport.set(ratatui::layout::Rect::new(28, 1, 10, 10));
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 50,
row: 50, modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert!(!app.copy_mode.active);
}
#[test]
fn mouse_drag_starts_selection() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = App::new(crate::config::Config::default(), crate::state::AppSession::default());
app.focus = Focus::Detail;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(28, 1, 80, 20));
app.pr_detail_viewport.set(ratatui::layout::Rect::new(28, 1, 80, 20));
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 30,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert!(app.copy_mode.active);
assert!(app.copy_mode.anchor.is_none());
app.handle_action(Action::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 33,
row: 1,
modifiers: crossterm::event::KeyModifiers::NONE,
}));
assert_eq!(app.copy_mode.anchor, Some(crate::ui::copy_mode::Pos { row: 0, col: 2 }));
assert_eq!(app.copy_mode.cursor.col, 5);
}
#[test]
fn pressing_p_opens_repo_picker() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.handle_action(Action::OpenRepoPicker);
assert_eq!(app.focus, Focus::RepoPicker);
}
#[test]
fn open_repo_picker_resets_state() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.repo_picker_input = "stale/input".to_owned();
app.repo_picker_mode = RepoPickerMode::Input;
app.handle_action(Action::OpenRepoPicker);
assert_eq!(app.focus, Focus::RepoPicker);
assert!(app.repo_picker_input.is_empty(), "input buffer should be cleared on open");
assert_eq!(app.repo_picker_mode, RepoPickerMode::List);
}
#[test]
fn close_repo_picker_restores_focus() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Dashboard;
app.handle_action(Action::OpenRepoPicker);
assert_eq!(app.focus, Focus::RepoPicker);
app.close_repo_picker();
assert_eq!(app.focus, Focus::Dashboard);
}
#[test]
fn bracket_keys_resize_sidebar_in_detail() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let initial_tab = app.tabs.active_index();
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Detail, "] must not leave Detail focus");
assert_eq!(app.tabs.active_index(), initial_tab, "] must not switch tabs in Detail");
assert_eq!(app.sidebar_width, 30, "] widens sidebar by 2");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.tabs.active_index(), initial_tab, "[ must not switch tabs in Detail");
assert_eq!(app.sidebar_width, 28, "[ narrows sidebar by 2");
app.sidebar_width = 21;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.sidebar_width, 20, "[ clamps at minimum 20 (step: 21 -> 20)");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.sidebar_width, 20, "[ is a no-op when sidebar is already at minimum");
app.sidebar_width = 59;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.sidebar_width, 60, "] clamps at maximum 60 (step: 59 -> 60)");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.sidebar_width, 60, "] is a no-op when sidebar is already at maximum");
}
#[test]
fn bracket_keys_still_switch_tabs_from_dashboard() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
assert_eq!(app.focus, Focus::Dashboard);
assert_eq!(app.tabs.active_index(), Some(0));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.tabs.active_index(), Some(1), "] switches to next tab from Dashboard");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.tabs.active_index(), Some(0), "[ switches to prev tab from Dashboard");
}
#[test]
fn backslash_toggles_sidebar_visibility() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
assert!(!app.sidebar_hidden, "sidebar visible by default");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('\\'),
crossterm::event::KeyModifiers::NONE,
));
assert!(app.sidebar_hidden, "first \\ hides sidebar");
assert!(app.flash.is_some(), "flash shown after hide");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('\\'),
crossterm::event::KeyModifiers::NONE,
));
assert!(!app.sidebar_hidden, "second \\ un-hides sidebar");
assert!(app.flash.is_some(), "flash shown after un-hide");
}
#[test]
fn dollar_enters_files_overview_mode() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_files_show_diff = true;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('$'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Files);
assert!(!app.pr_detail_files_show_diff, "$ must enter overview mode");
}
#[test]
fn shift_f_enters_files_diff_mode() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_files_show_diff = false;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('F'),
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Files);
assert!(app.pr_detail_files_show_diff, "F must enter diff mode");
}
#[test]
fn clicking_sidebar_file_enters_diff_mode() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 3, 0));
app.pr_detail_files_show_diff = false;
let sections_rect = ratatui::layout::Rect { x: 0, y: 0, width: 28, height: 7 };
let files_rect = ratatui::layout::Rect { x: 0, y: 7, width: 28, height: 20 };
app.handle_sidebar_click(0, 8, sections_rect, files_rect);
assert_eq!(app.pr_detail_selected_section, DetailSection::Files);
assert!(app.pr_detail_files_show_diff, "sidebar file click must enable diff mode");
assert_eq!(app.pr_detail_files_cursor, 0);
}
#[test]
fn tab_switch_from_detail_with_no_loaded_detail_falls_back_to_dashboard() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_action(Action::SwitchTab(1));
assert_eq!(app.focus, Focus::Dashboard);
assert!(app.pr_detail.is_none());
}
#[test]
fn tab_round_trip_preserves_detail_focus_for_pr() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let pr = fixture_pr_detail(0, 0, 0, 0);
app.focus = Focus::Detail;
app.pr_detail = Some(pr);
app.handle_action(Action::SwitchTab(1));
assert_eq!(app.focus, Focus::Dashboard, "tab 1 has no saved detail");
assert!(app.pr_detail.is_none(), "detail payload must clear between tabs");
app.handle_action(Action::SwitchTab(0));
assert_eq!(
app.focus,
Focus::Detail,
"round-tripping back to a tab with a saved detail ref must restore Detail focus"
);
}
#[test]
fn back_to_dashboard_clears_saved_detail_ref() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 0, 0));
app.back_to_dashboard();
app.handle_action(Action::SwitchTab(1));
app.handle_action(Action::SwitchTab(0));
assert_eq!(
app.focus,
Focus::Dashboard,
"after Esc the saved ref is gone so round-trip lands on list"
);
}
#[test]
fn repo_picker_input_accepts_digits() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
for ch in ['0', 'x', '/', '1', '9'] {
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(ch),
crossterm::event::KeyModifiers::NONE,
));
}
assert_eq!(app.repo_picker_input, "0x/19", "digits must reach input buffer");
});
}
#[test]
fn repo_picker_input_accepts_shifted_uppercase() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
app.handle_repo_picker_input_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('I'),
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(app.repo_picker_input, "I");
});
}
#[test]
fn repo_picker_input_rejects_ctrl_modified_keys() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
app.handle_repo_picker_input_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::CONTROL,
));
assert!(app.repo_picker_input.is_empty(), "Ctrl-keys must not type");
});
}
#[test]
fn repo_picker_add_valid_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
app.repo_picker_input = "rust-lang/rust".to_owned();
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_repo_picker_input_key(key);
assert!(app.config.repos.contains(&"rust-lang/rust".to_owned()));
assert!(app.repo_picker_input.is_empty(), "buffer must be cleared after successful add");
});
}
#[test]
fn repo_picker_add_dedup() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config {
repos: vec!["rust-lang/rust".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
app.repo_picker_input = "rust-lang/rust".to_owned();
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_repo_picker_input_key(key);
assert_eq!(
app.config.repos.iter().filter(|r| *r == "rust-lang/rust").count(),
1,
"duplicate repo must not be added"
);
});
}
#[test]
fn repo_picker_add_invalid_slug_sets_flash() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::Input;
app.repo_picker_input = "no-slash-here".to_owned();
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_repo_picker_input_key(key);
assert!(app.config.repos.is_empty(), "invalid slug must not be added");
assert!(app.flash.is_some(), "flash message must be set on validation failure");
});
}
#[test]
fn repo_picker_delete_cleans_up_selection_map() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config {
repos: vec!["owner/a".to_owned(), "owner/b".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.selection.insert("owner/a".to_owned(), 3);
app.selection.insert("owner/b".to_owned(), 1);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::List;
app.repo_picker_list_cursor = 0;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_repo_picker_list_key(key);
assert!(
!app.selection.contains_key("owner/a"),
"deleted repo's selection entry must be removed"
);
assert_eq!(
app.selection.get("owner/b"),
Some(&1),
"other repos' selection entries must be untouched"
);
});
}
#[test]
fn repo_picker_delete_removes_repo_and_tab() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config {
repos: vec!["owner/a".to_owned(), "owner/b".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::RepoPicker;
app.repo_picker_mode = RepoPickerMode::List;
app.repo_picker_list_cursor = 0;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_repo_picker_list_key(key);
assert!(!app.config.repos.contains(&"owner/a".to_owned()), "repo must be removed");
assert!(app.config.repos.contains(&"owner/b".to_owned()), "other repo must remain");
assert!(
app.tabs.tabs.iter().all(|t| t.repo != "owner/a"),
"tab for deleted repo must be closed"
);
});
}
#[test]
fn config_save_respects_override() {
let tmp = tempfile::tempdir().expect("tempdir");
let expected = tmp.path().join("config.toml");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config {
repos: vec!["sentinel/override".to_owned()],
..Default::default()
};
config.save();
assert!(expected.exists(), "save must write to the override path");
let written = std::fs::read_to_string(&expected).expect("read override");
assert!(written.contains("sentinel/override"), "override file must contain the data");
});
}
#[test]
fn pressing_c_on_dashboard_with_pr_opens_confirm() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = Inbox {
viewer_login: "viewer".to_owned(),
prs: vec![make_pr("o/r", "clean", "viewer")],
issues: vec![],
};
app.on_inbox_loaded(inbox);
app.handle_action(Action::CheckoutBranch);
match app.focus {
Focus::Confirm => {
assert!(app.confirm.is_some(), "confirm must be populated");
let confirm = app.confirm.as_ref().unwrap();
assert!(
matches!(
&confirm.pending_action,
crate::ui::confirm::ConfirmPending::CheckoutBranch { branch, .. }
if branch == "feat/test"
),
"confirm must have the correct branch"
);
}
Focus::Dashboard => {
assert!(
app.flash.is_some(),
"a flash must be set when not in a git repo or branch is unavailable"
);
}
other => panic!("unexpected focus {other:?}"),
}
}
#[test]
fn confirm_n_cancels_and_restores_focus() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.confirm = Some(crate::ui::confirm::Confirm {
title: "Test".to_owned(),
prompt: "Are you sure?".to_owned(),
pending_action: crate::ui::confirm::ConfirmPending::CheckoutBranch {
repo: "o/r".to_owned(),
number: 1,
branch: "feat/x".to_owned(),
},
});
app.confirm_return_focus = Focus::Dashboard;
app.focus = Focus::Confirm;
app.handle_action(Action::ConfirmCheckout(false));
assert_eq!(app.focus, Focus::Dashboard, "focus must be restored after cancel");
assert!(app.confirm.is_none(), "confirm must be cleared after cancel");
}
#[test]
fn merge_shortcut_opens_confirm_with_head_sha_guard() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 0, 0));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('M'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Confirm);
let confirm = app.confirm.as_ref().expect("merge confirmation");
assert!(matches!(
&confirm.pending_action,
crate::ui::confirm::ConfirmPending::MergePullRequest {
method: crate::github::mutations::MergeMethod::Merge,
expected_head_sha,
..
} if expected_head_sha == "0123456789abcdef0123456789abcdef01234567"
));
}
#[test]
fn squash_shortcut_opens_confirm() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 0, 0));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('S'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Confirm);
assert!(matches!(
app.confirm.as_ref().map(|c| &c.pending_action),
Some(crate::ui::confirm::ConfirmPending::MergePullRequest {
method: crate::github::mutations::MergeMethod::Squash,
..
})
));
}
#[test]
fn composer_keystrokes_edit_and_empty_submit_stays_open() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_action(Action::OpenCommentComposer(super::types::CommentComposerTarget::TopLevel {
repo: "o/r".to_owned(),
number: 1,
subject_id: "PR_node".to_owned(),
kind: super::types::CommentSubjectKind::PullRequest,
}));
assert_eq!(app.focus, Focus::Composer);
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('h'),
crossterm::event::KeyModifiers::NONE,
));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('i'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.composer.as_ref().map(|c| c.body.as_str()), Some("h\ni"));
app.composer.as_mut().expect("composer").body.clear();
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('s'),
crossterm::event::KeyModifiers::CONTROL,
));
assert_eq!(app.focus, Focus::Composer, "empty submit must keep composer open");
assert!(app.composer.is_some(), "empty submit must preserve draft state");
}
#[test]
fn failed_comment_mutation_restores_pending_draft() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let draft = super::types::CommentComposer {
target: super::types::CommentComposerTarget::TopLevel {
repo: "o/r".to_owned(),
number: 1,
subject_id: "PR_node".to_owned(),
kind: super::types::CommentSubjectKind::PullRequest,
},
body: "please keep this".to_owned(),
};
app.pending_comment_draft = Some(draft);
app.pending_mutation = Some(super::types::PendingMutation::SubmitComment {
target: super::types::CommentComposerTarget::TopLevel {
repo: "o/r".to_owned(),
number: 1,
subject_id: "PR_node".to_owned(),
kind: super::types::CommentSubjectKind::PullRequest,
},
});
app.handle_action(Action::MutationFailed("Comment failed: nope".to_owned()));
assert_eq!(app.focus, Focus::Composer);
assert_eq!(
app.composer.as_ref().map(|c| c.body.as_str()),
Some("please keep this"),
"failed submit must not lose typed markdown"
);
assert!(app.pending_mutation.is_none());
}
#[test]
fn reply_shortcut_targets_focused_diff_thread() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
use crate::ui::pr_detail::{DetailSection, build_thread_index};
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = fixture_pr_detail(0, 0, 1, 1);
app.thread_index = Some(build_thread_index(&detail));
app.pr_detail = Some(detail);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = true;
*app.pr_detail_diff_cursor.borrow_mut() = Some(("src/file-0.rs".to_owned(), 5));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('R'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Composer);
assert!(matches!(
app.composer.as_ref().map(|c| &c.target),
Some(super::types::CommentComposerTarget::ReviewThreadReply {
thread_id,
path,
line: Some(5),
..
}) if thread_id == "THREAD_node" && path == "src/file-0.rs"
));
}
fn make_inbox(prs: Vec<(&str, &str)>, issues: Vec<&str>) -> Inbox {
Inbox {
viewer_login: "viewer".to_owned(),
prs: prs.into_iter().map(|(repo, variant)| make_pr(repo, variant, "viewer")).collect(),
issues: issues.into_iter().map(make_issue).collect(),
}
}
#[test]
fn on_inbox_loaded_triggers_first_run_when_config_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default(); let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(
vec![("alice/foo", "clean"), ("bob/bar", "clean"), ("alice/foo", "conflict")],
vec![],
);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::FirstRun, "focus must switch to FirstRun");
assert_eq!(
app.first_run_suggestions.len(),
2,
"two distinct repos must appear in suggestions"
);
assert_eq!(app.first_run_suggestions[0].repo, "alice/foo");
assert_eq!(app.first_run_suggestions[0].count, 2);
assert_eq!(app.first_run_suggestions[1].repo, "bob/bar");
assert_eq!(app.first_run_suggestions[1].count, 1);
});
}
#[test]
fn on_inbox_loaded_skips_first_run_when_config_nonempty() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config =
crate::config::Config { repos: vec!["existing/repo".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(vec![("alice/foo", "clean")], vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(
app.focus,
Focus::Dashboard,
"focus must remain Dashboard when config has repos"
);
assert!(app.first_run_suggestions.is_empty(), "no suggestions when config is nonempty");
});
}
#[test]
fn on_inbox_loaded_skips_first_run_when_inbox_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(vec![], vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::Dashboard, "focus must stay Dashboard for empty inbox");
assert!(app.first_run_suggestions.is_empty());
});
}
#[test]
fn first_run_space_toggles_selection() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::FirstRun;
app.first_run_suggestions =
vec![FirstRunSuggestion { repo: "a/b".to_owned(), count: 1, selected: false }];
app.first_run_cursor = 0;
let space = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(space);
assert!(app.first_run_suggestions[0].selected, "Space must select the row");
let space2 = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(space2);
assert!(!app.first_run_suggestions[0].selected, "second Space must deselect the row");
});
}
#[test]
fn first_run_enter_commits_selected() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::FirstRun;
app.first_run_suggestions = vec![
FirstRunSuggestion { repo: "a/b".to_owned(), count: 5, selected: true },
FirstRunSuggestion { repo: "c/d".to_owned(), count: 3, selected: true },
FirstRunSuggestion { repo: "e/f".to_owned(), count: 1, selected: false },
];
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(enter);
assert_eq!(app.focus, Focus::Dashboard, "focus must switch to Dashboard after commit");
assert!(app.first_run_suggestions.is_empty(), "suggestions must be cleared");
assert!(
app.config.repos.contains(&"a/b".to_owned()),
"selected repo a/b must be in config"
);
assert!(
app.config.repos.contains(&"c/d".to_owned()),
"selected repo c/d must be in config"
);
assert!(
!app.config.repos.contains(&"e/f".to_owned()),
"unselected repo e/f must NOT be in config"
);
assert!(app.flash.is_some(), "a flash message must be set after committing");
});
}
#[test]
fn first_run_esc_skips_without_commit() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::FirstRun;
app.first_run_suggestions =
vec![FirstRunSuggestion { repo: "a/b".to_owned(), count: 2, selected: true }];
let esc = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(esc);
assert_eq!(app.focus, Focus::Dashboard, "focus must be Dashboard after Esc");
assert!(app.config.repos.is_empty(), "Esc must not commit any repos to config");
assert!(app.first_run_suggestions.is_empty(), "suggestions must be cleared on Esc");
});
}
#[test]
fn first_run_suggestions_sorted_by_count_desc() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let mut prs: Vec<(&str, &str)> = Vec::new();
for _ in 0..5 {
prs.push(("a/b", "clean"));
}
for _ in 0..10 {
prs.push(("c/d", "clean"));
}
let inbox = make_inbox(prs, vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::FirstRun, "must switch to FirstRun");
assert_eq!(app.first_run_suggestions[0].repo, "c/d", "repo with more items must be first");
assert_eq!(app.first_run_suggestions[0].count, 10);
});
}
#[test]
fn first_run_suggestion_counts_pr_plus_issue() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox =
make_inbox(vec![("x/y", "clean"), ("x/y", "conflict")], vec!["x/y", "x/y", "x/y"]);
app.on_inbox_loaded(inbox);
let sug = app.first_run_suggestions.iter().find(|s| s.repo == "x/y");
assert!(sug.is_some(), "x/y must appear in suggestions");
assert_eq!(sug.unwrap().count, 5, "2 PRs + 3 issues = 5 total");
});
}
#[test]
fn first_run_survives_mid_wizard_refresh() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(vec![("a/b", "clean"), ("c/d", "clean")], vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::FirstRun);
assert_eq!(app.first_run_suggestions.len(), 2);
app.first_run_cursor = 0;
app.first_run_suggestions[0].selected = true;
let snapshot_repo = app.first_run_suggestions[0].repo.clone();
let inbox2 = make_inbox(vec![("a/b", "clean"), ("c/d", "clean"), ("e/f", "clean")], vec![]);
app.on_inbox_loaded(inbox2);
assert_eq!(app.focus, Focus::FirstRun, "focus must not bounce");
assert_eq!(app.first_run_suggestions.len(), 2, "suggestions must not be rebuilt");
assert_eq!(
app.first_run_suggestions[0].repo, snapshot_repo,
"suggestion ordering must be preserved"
);
assert!(app.first_run_suggestions[0].selected, "user's selection must survive the refresh");
});
}
#[test]
fn first_run_a_roundtrips_back_to_first_run() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(vec![("a/b", "clean")], vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::FirstRun, "wizard must be active");
let a_key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(a_key);
assert_eq!(app.focus, Focus::RepoPicker);
assert_eq!(
app.repo_picker_return_focus,
Focus::FirstRun,
"return-focus must be recorded so the picker close path returns here"
);
app.close_repo_picker();
assert_eq!(app.focus, Focus::FirstRun, "closing picker must return to wizard");
});
}
#[test]
fn first_run_enter_with_nothing_selected_flashes_hint() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let inbox = make_inbox(vec![("a/b", "clean")], vec![]);
app.on_inbox_loaded(inbox);
assert_eq!(app.focus, Focus::FirstRun);
assert!(
!app.first_run_suggestions.iter().any(|s| s.selected),
"no suggestions should start selected"
);
let enter = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
);
app.handle_key_first_run(enter);
assert_eq!(app.focus, Focus::FirstRun, "wizard must stay open on empty Enter");
assert!(app.flash.is_some(), "a hint flash must be shown");
assert!(app.config.repos.is_empty(), "config must not be mutated");
});
}
#[test]
fn toggle_show_all_flips_flag_and_persists() {
let dir = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(dir.path(), || {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
assert!(!app.config.show_all_prs);
app.handle_action(Action::ToggleShowAll);
assert!(app.config.show_all_prs, "flag must be true after first toggle");
assert!(app.flash.is_some(), "a flash message must be shown");
let saved = crate::config::Config::load();
assert!(saved.show_all_prs, "persisted config must reflect the toggle");
app.handle_action(Action::ToggleShowAll);
assert!(!app.config.show_all_prs, "flag must be false after second toggle");
let saved2 = crate::config::Config::load();
assert!(!saved2.show_all_prs, "persisted config must reflect the second toggle");
});
}
#[test]
fn capital_a_on_dashboard_triggers_show_all_toggle() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
assert!(!app.config.show_all_prs);
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('A'),
crossterm::event::KeyModifiers::SHIFT,
));
assert!(
app.config.show_all_prs,
"SHIFT+a must dispatch ToggleShowAll despite the modifier"
);
});
}
#[test]
fn c_on_dashboard_opens_theme_picker() {
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState};
let config = crate::config::Config { theme: Theme::Nord, ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
assert_eq!(app.focus, Focus::Dashboard);
let key = KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
app.handle_key(key);
assert_eq!(app.focus, Focus::ThemePicker, "focus must switch to ThemePicker");
let expected_idx = Theme::ALL.iter().position(|&t| t == Theme::Nord).unwrap();
assert_eq!(app.theme_picker_cursor, expected_idx, "cursor must start on the current theme");
}
#[test]
fn enter_in_theme_picker_applies_and_persists() {
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState};
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config { theme: Theme::Default, ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.open_theme_picker();
app.theme_picker_cursor = 1;
let key = KeyEvent {
code: KeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
app.handle_key_theme_picker(key);
assert_eq!(app.config.theme, Theme::Dracula, "in-memory theme must be Dracula");
assert_eq!(app.focus, Focus::Dashboard, "picker must close");
let saved = crate::config::Config::load();
assert_eq!(saved.theme, Theme::Dracula, "persisted theme must be Dracula");
});
}
#[test]
fn esc_in_theme_picker_restores_original_theme() {
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState};
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let config = crate::config::Config { theme: Theme::Nord, ..Default::default() };
config.save();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.open_theme_picker();
app.theme_picker_cursor = 1;
let key = KeyEvent {
code: KeyCode::Esc,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
app.handle_key_theme_picker(key);
assert_eq!(app.config.theme, Theme::Nord, "in-memory theme must revert to Nord");
assert_eq!(app.focus, Focus::Dashboard, "picker must close");
let saved = crate::config::Config::load();
assert_eq!(saved.theme, Theme::Nord, "persisted theme must remain Nord");
});
}
#[test]
fn cursor_wraps_around_at_list_edges() {
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState};
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.open_theme_picker();
let last = Theme::ALL.len() - 1;
app.theme_picker_cursor = 0;
let up = KeyEvent {
code: KeyCode::Up,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
app.handle_key_theme_picker(up);
assert_eq!(app.theme_picker_cursor, last, "Up from 0 must wrap to last index");
let down = KeyEvent {
code: KeyCode::Down,
modifiers: crossterm::event::KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
app.handle_key_theme_picker(down);
assert_eq!(app.theme_picker_cursor, 0, "Down from last must wrap to 0");
}
#[test]
fn shift_digit_variants_select_sections() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
for (ch, expected) in [
('!', DetailSection::Description),
('@', DetailSection::Checks),
('#', DetailSection::Reviews),
('$', DetailSection::Files),
('%', DetailSection::Comments),
] {
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Description;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(ch),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_selected_section, expected, "{ch:?} must select {expected:?}");
}
}
#[test]
fn modifier_circumflex_selects_commits_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('ˆ'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Commits);
app.pr_detail_selected_section = DetailSection::Description;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('ˆ'),
crossterm::event::KeyModifiers::ALT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Commits);
}
#[test]
fn capital_c_selects_commits_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('C'),
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Commits);
}
#[test]
fn modified_shift_six_selects_commits_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
for modifiers in [
crossterm::event::KeyModifiers::SHIFT,
crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::ALT,
crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::CONTROL,
] {
app.pr_detail_selected_section = DetailSection::Description;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('6'),
modifiers,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Commits);
}
}
#[test]
fn modified_punctuation_still_selects_sections() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('@'),
crossterm::event::KeyModifiers::ALT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Checks);
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('#'),
crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Reviews);
}
#[test]
fn shift_plus_digit_also_selects_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('3'),
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Reviews);
}
#[test]
fn shift_f_selects_files_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('F'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_selected_section, DetailSection::Files);
}
#[test]
fn shift_j_k_cycle_files_cursor_in_files_section() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 5, 0));
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_cursor = 0;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 1, "J moves cursor forward");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 4, "cycle advances to last file");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 4, "J at last clamps (no wrap)");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('K'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 3, "K moves cursor back");
}
#[test]
fn shift_j_k_do_not_cycle_outside_files_section() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 5, 0));
app.pr_detail_selected_section = DetailSection::Description;
app.pr_detail_files_cursor = 2;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 2, "J outside Files must not move cursor");
}
#[test]
fn arrow_keys_cycle_files_cursor_in_files_overview() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 3, 0));
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = false;
app.pr_detail_files_cursor = 0;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 1, "Down moves to next file");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.pr_detail_files_cursor, 0, "Up moves to previous file");
}
#[test]
fn esc_from_unscoped_files_diff_returns_to_files_overview() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 3, 0));
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = true;
app.pr_detail_files_cursor = 2;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Detail, "Esc from Files diff stays in detail");
assert_eq!(app.pr_detail_selected_section, DetailSection::Files);
assert!(
!app.pr_detail_files_show_diff,
"Esc from Files diff should return to the Files overview"
);
assert_eq!(app.pr_detail_files_cursor, 2, "selected file should be preserved");
}
#[test]
fn dashboard_selection_opens_displayed_pr() {
use crate::github::types::{Inbox, sorted_prs_for_repo};
use chrono::Duration;
let older = {
let mut p = make_pr("o/r", "clean", "viewer");
p.number = 10;
p.updated_at = Utc::now() - Duration::days(5);
p.url = "https://github.com/o/r/pull/10".to_owned();
p
};
let newer = {
let mut p = make_pr("o/r", "clean", "viewer");
p.number = 20;
p.updated_at = Utc::now();
p.url = "https://github.com/o/r/pull/20".to_owned();
p
};
let inbox =
Inbox { viewer_login: "viewer".to_owned(), prs: vec![older, newer], issues: vec![] };
let display = sorted_prs_for_repo(&inbox, "o/r");
assert_eq!(display[0].number, 20, "display row 0 must be the most-recently-updated PR");
assert_eq!(display[1].number, 10);
}
#[test]
fn scroll_clamp_accounts_for_line_wrap() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let mut detail = fixture_pr_detail(0, 0, 0, 0);
detail.body_markdown = "x ".repeat(250); app.pr_detail = Some(detail);
app.pr_detail_selected_section = crate::ui::pr_detail::DetailSection::Description;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(0, 0, 40, 5));
*app.right_pane_scroll_mut() = u16::MAX;
app.clamp_pr_detail_scroll();
assert!(
app.right_pane_scroll() > 0,
"wrap-aware clamp must allow scrolling into wrapped content; got {}",
app.right_pane_scroll()
);
}
#[test]
fn files_scroll_is_clamped_to_diff_length() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 3, 0));
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_cursor = 0;
app.pr_detail_right_viewport.set(ratatui::layout::Rect::new(30, 6, 100, 24));
*app.right_pane_scroll_mut() = u16::MAX;
app.clamp_pr_detail_scroll();
assert_eq!(app.right_pane_scroll(), 0, "diff shorter than viewport must clamp scroll to 0");
}
#[test]
fn diff_scroll_is_preserved_per_file() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(0, 0, 3, 0));
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_cursor = 0;
*app.right_pane_scroll_mut() = 7;
assert_eq!(app.right_pane_scroll(), 7);
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('J'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.right_pane_scroll(), 0, "new file's scroll starts at 0");
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('K'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.right_pane_scroll(), 7, "first file's scroll is remembered");
}
#[test]
fn digit_in_detail_switches_repo_tab_not_section() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Reviews;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('2'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Dashboard, "digit in detail pops to dashboard");
assert_eq!(app.tabs.active_index(), Some(1));
}
#[test]
fn current_detail_lines_returns_only_selected_section() {
use crate::ui::pr_detail::tests::fixture_pr_detail;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail = Some(fixture_pr_detail(3, 2, 4, 2));
app.pr_detail_selected_section = DetailSection::Description;
let desc_lines = app.current_detail_lines();
app.pr_detail_selected_section = DetailSection::Checks;
let check_lines = app.current_detail_lines();
assert_ne!(
desc_lines.len(),
check_lines.len(),
"Description and Checks must produce different line buffers"
);
assert!(!desc_lines.is_empty(), "Description must have lines");
assert!(!check_lines.is_empty(), "Checks must have lines for non-empty fixture");
}
#[test]
fn mouse_click_on_sidebar_section_row_selects_that_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let sections_rect = ratatui::layout::Rect::new(0, 4, 28, 7);
let files_rect = ratatui::layout::Rect::new(0, 11, 28, 20);
app.pr_detail_sidebar_rects.set((sections_rect, files_rect));
app.handle_sidebar_click(5, 7, sections_rect, files_rect);
assert_eq!(
app.pr_detail_selected_section,
DetailSection::Reviews,
"clicking row 7 in sections panel (relative 3 = section index 2) must select Reviews"
);
}
#[test]
fn scroll_is_preserved_per_section() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Description;
*app.scroll_mut(DetailSection::Description) = 15;
app.pr_detail_selected_section = DetailSection::Checks;
assert_eq!(app.scroll_for(DetailSection::Checks), 0, "fresh section starts at scroll 0");
app.pr_detail_selected_section = DetailSection::Description;
assert_eq!(
app.scroll_for(DetailSection::Description),
15,
"switching back to Description must restore scroll 15"
);
}
fn make_pr_detail_for_app(repo: &str, number: u32) -> crate::github::detail::PrDetail {
crate::github::detail::PrDetail {
node_id: "PR_node".to_owned(),
repo: repo.to_owned(),
number,
title: "Cache Test PR".to_owned(),
url: format!("https://github.com/{repo}/pull/{number}"),
author: "alice".to_owned(),
body_markdown: String::new(),
base_ref: "main".to_owned(),
head_ref: "feat/cache".to_owned(),
head_oid: "0123456789abcdef0123456789abcdef01234567".to_owned(),
is_draft: false,
additions: 1,
deletions: 1,
changed_files_count: 1,
updated_at: Utc::now(),
created_at: Utc::now(),
merged: false,
files: vec![],
check_runs: vec![],
reviews: vec![],
review_threads: vec![],
issue_comments: vec![],
commits: vec![],
}
}
#[test]
fn cache_insert_and_get_pr() {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("o/r", 42);
app.detail_cache.insert_pr(detail.clone());
let hit = app.detail_cache.get_pr("o/r", 42).expect("cache miss");
assert_eq!(hit.data.number, 42);
assert_eq!(hit.data.repo, "o/r");
}
#[test]
fn cache_is_fresh_true_under_ttl_false_after() {
use crate::github::cache::{CACHE_TTL, Cached};
use std::time::{Duration, Instant};
let data = make_pr_detail_for_app("o/r", 1);
let fresh = Cached::new(data.clone());
assert!(fresh.is_fresh(), "entry stamped now must be fresh");
let stale = Cached {
data,
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(CACHE_TTL.as_secs() + 1))
.unwrap_or_else(Instant::now),
};
assert!(!stale.is_fresh(), "entry older than TTL must be stale");
}
#[test]
fn restore_from_fresh_cache_populates_detail_without_flipping_fetching() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("a/one", 1);
app.detail_cache.insert_pr(detail.clone());
app.per_tab_state.insert(
"a/one".to_owned(),
PerTabState {
detail_ref: Some(DetailRef {
repo: "a/one".to_owned(),
number: 1,
kind: DetailKind::Pr,
}),
},
);
app.tabs.set_active_by_index(1);
app.tabs.set_active_by_index(0);
app.restore_active_tab_state();
assert!(app.pr_detail.is_some(), "pr_detail must be populated from cache");
assert!(!app.detail_fetching, "no spinner for a cache hit");
assert!(app.detail_refreshing.is_none(), "no SWR kick for a fresh entry");
}
#[test]
fn restored_pr_cache_rebuilds_thread_index_for_file_thread_shortcut() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let now = Utc::now();
let mut detail = make_pr_detail_for_app("o/r", 1);
detail.files = vec![crate::github::detail::FileChange {
path: "src/lib.rs".to_owned(),
additions: 1,
deletions: 0,
change_kind: crate::github::detail::FileChangeKind::Modified,
patch: Some("@@ -1,2 +1,2 @@\n line one\n line two".to_owned()),
}];
detail.review_threads = vec![crate::github::detail::ReviewThread {
node_id: "THREAD_node".to_owned(),
path: "src/lib.rs".to_owned(),
line: Some(2),
start_line: None,
is_resolved: false,
is_outdated: false,
diff_hunk: None,
comments: vec![crate::github::detail::ReviewComment {
node_id: "COMMENT_node".to_owned(),
author: "reviewer".to_owned(),
body_markdown: "please check".to_owned(),
created_at: now,
diff_hunk: None,
original_commit_id: None,
}],
}];
app.detail_cache.insert_pr(detail);
app.per_tab_state.insert(
"o/r".to_owned(),
PerTabState {
detail_ref: Some(DetailRef { repo: "o/r".to_owned(), number: 1, kind: DetailKind::Pr }),
},
);
app.restore_active_tab_state();
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = true;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('t'),
crossterm::event::KeyModifiers::NONE,
));
assert!(
app.pr_detail_expanded_threads.contains(&("src/lib.rs".to_owned(), 2)),
"`t` must expand the inline thread after restoring detail from cache"
);
}
#[test]
fn restore_from_stale_cache_populates_and_sets_refreshing() {
use crate::github::cache::{CACHE_TTL, Cached};
use std::time::{Duration, Instant};
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let data = make_pr_detail_for_app("a/one", 1);
app.detail_cache.prs.insert(
("a/one".to_owned(), 1),
Cached {
data,
fetched_at: Instant::now()
.checked_sub(Duration::from_secs(CACHE_TTL.as_secs() + 1))
.unwrap_or_else(Instant::now),
},
);
app.per_tab_state.insert(
"a/one".to_owned(),
PerTabState {
detail_ref: Some(DetailRef {
repo: "a/one".to_owned(),
number: 1,
kind: DetailKind::Pr,
}),
},
);
app.tabs.set_active_by_index(0);
app.restore_active_tab_state();
assert!(app.pr_detail.is_some(), "stale cache must still populate pr_detail immediately");
assert!(!app.detail_fetching, "stale SWR must NOT set the spinner");
assert_eq!(
app.detail_refreshing,
Some(("a/one".to_owned(), 1)),
"stale entry must set detail_refreshing"
);
}
#[test]
fn cold_miss_falls_back_to_cold_fetch_path() {
let config = crate::config::Config {
repos: vec!["a/one".to_owned(), "b/two".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.per_tab_state.insert(
"a/one".to_owned(),
PerTabState {
detail_ref: Some(DetailRef {
repo: "a/one".to_owned(),
number: 1,
kind: DetailKind::Pr,
}),
},
);
app.tabs.set_active_by_index(0);
app.restore_active_tab_state();
assert_eq!(app.focus, Focus::Detail, "detail ref present means focus=Detail");
assert!(app.pr_detail.is_none(), "no cache entry means cold fetch (no stale content)");
assert!(app.detail_refreshing.is_none(), "cold miss uses foreground fetch, not SWR");
}
#[test]
fn pr_detail_loaded_upserts_cache_and_clears_refreshing() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.detail_refreshing = Some(("o/r".to_owned(), 5));
app.focus = Focus::Detail;
app.detail_fetching = true;
let detail = make_pr_detail_for_app("o/r", 5);
app.handle_action(Action::PrDetailLoaded(Box::new(detail)));
assert!(app.detail_cache.get_pr("o/r", 5).is_some(), "cache must be populated");
assert!(app.detail_refreshing.is_none(), "SWR marker must be cleared on arrival");
}
#[test]
fn pr_detail_loaded_ignored_when_user_moved_on() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Dashboard;
app.pr_detail = None;
let detail = make_pr_detail_for_app("o/r", 7);
app.handle_action(Action::PrDetailLoaded(Box::new(detail)));
assert!(app.detail_cache.get_pr("o/r", 7).is_some(), "cache must be populated");
assert!(app.pr_detail.is_none(), "visible state must NOT be updated when not in Detail");
}
#[test]
fn manual_refresh_invalidates_cache() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("o/r", 3);
app.detail_cache.insert_pr(detail.clone());
app.pr_detail = Some(detail);
app.focus = Focus::Detail;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('r'),
crossterm::event::KeyModifiers::NONE,
));
assert!(
app.detail_cache.get_pr("o/r", 3).is_none(),
"manual refresh must invalidate the cache entry"
);
}
#[test]
fn back_to_dashboard_does_not_clear_cache() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("o/r", 9);
app.detail_cache.insert_pr(detail.clone());
app.pr_detail = Some(detail);
app.focus = Focus::Detail;
app.back_to_dashboard();
assert!(
app.detail_cache.get_pr("o/r", 9).is_some(),
"cache entry must survive back_to_dashboard"
);
}
#[test]
fn scope_to_commit_clears_thread_state() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Commits;
let detail = fixture_pr_detail_with_commits(2);
app.pr_detail = Some(detail);
app.pr_detail_expanded_threads.insert(("src/lib.rs".to_owned(), 10));
*app.pr_detail_diff_cursor.borrow_mut() = Some(("src/lib.rs".to_owned(), 10));
app.commits_cursor = 1;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.selected_commit, Some(1), "selected_commit must be set to commits_cursor");
assert_eq!(
app.pr_detail_selected_section,
DetailSection::Files,
"Enter on a commit should open the Files section"
);
assert!(app.pr_detail_files_show_diff, "Enter on a commit should open diff mode");
assert_eq!(app.pr_detail_files_cursor, 0, "commit diff starts at first touched file");
assert!(
app.pr_detail_expanded_threads.is_empty(),
"expanded_threads must be cleared when scoping"
);
assert!(
app.pr_detail_diff_cursor.borrow().is_none(),
"diff_cursor must be cleared when scoping"
);
}
#[test]
fn esc_from_commit_scoped_files_returns_to_commits_source() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Commits;
app.pr_detail = Some(fixture_pr_detail_with_commits(2));
app.commits_cursor = 1;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Detail, "Esc from scoped Files must stay in detail");
assert!(app.pr_detail.is_some(), "Esc from scoped Files must keep PR detail loaded");
assert_eq!(
app.pr_detail_selected_section,
DetailSection::Commits,
"Esc from scoped Files should return to the source Commits list"
);
assert_eq!(app.commits_cursor, 1, "the originating commit row should remain highlighted");
assert_eq!(app.selected_commit, Some(1), "the scoped commit context should be preserved");
}
#[test]
fn b_from_commit_scoped_files_returns_to_commits_source() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = true;
app.pr_detail = Some(fixture_pr_detail_with_commits(2));
app.selected_commit = Some(1);
app.commits_cursor = 0;
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('b'),
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.focus, Focus::Detail, "`b` from scoped Files must stay in detail");
assert_eq!(app.pr_detail_selected_section, DetailSection::Commits);
assert_eq!(app.commits_cursor, 1, "`b` should restore the scoped commit cursor");
}
#[test]
fn pr_detail_refresh_preserves_selected_commit_by_sha() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let detail = fixture_pr_detail_with_commits(3);
let repo = detail.repo.clone();
let number = detail.number;
let selected_sha = detail.commits[1].sha.clone();
app.pr_detail = Some(detail);
app.selected_commit = Some(1);
app.commits_cursor = 1;
let mut refreshed = fixture_pr_detail_with_commits(3);
refreshed.repo = repo;
refreshed.number = number;
refreshed.title = "Refreshed title".to_owned();
app.handle_action(Action::PrDetailLoaded(Box::new(refreshed)));
assert_eq!(
app.selected_commit
.and_then(|idx| app.pr_detail.as_ref().and_then(|d| d.commits.get(idx)))
.map(|commit| commit.sha.as_str()),
Some(selected_sha.as_str()),
"SWR refresh should preserve an existing commit scope by SHA"
);
assert_eq!(app.commits_cursor, 1, "commit cursor should also stay on the same SHA");
}
#[test]
fn commit_diff_failure_after_cached_success_keeps_scope() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let detail = fixture_pr_detail_with_commits(1);
let repo = detail.repo.clone();
let sha = detail.commits[0].sha.clone();
app.pr_detail = Some(detail);
app.selected_commit = Some(0);
app.commit_diff_fetching.insert((repo.clone(), sha.clone()));
let mut patches = std::collections::HashMap::new();
patches.insert("src/lib.rs".to_owned(), Some("@@ -1 +1 @@\n-old\n+new".to_owned()));
app.handle_action(Action::CommitDiffLoaded(repo.clone(), sha.clone(), patches));
app.handle_action(Action::CommitDiffFailed(
repo.clone(),
sha.clone(),
"late duplicate failure".to_owned(),
));
assert_eq!(
app.selected_commit,
Some(0),
"late failure must not clear a scope that already has cached patches"
);
assert!(app.detail_cache.get_commit_patches(&repo, &sha).is_some());
assert!(
!app.commit_diff_fetching.contains(&(repo, sha)),
"loaded/failed actions should clear the in-flight marker"
);
}
#[test]
fn commit_diff_cache_counts_track_ready_and_inflight() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = fixture_pr_detail_with_commits(3);
let repo = detail.repo.clone();
let ready_sha = detail.commits[0].sha.clone();
let inflight_sha = detail.commits[1].sha.clone();
app.pr_detail = Some(detail);
let mut patches = std::collections::HashMap::new();
patches.insert("src/lib.rs".to_owned(), Some("@@ -1 +1 @@\n-old\n+new".to_owned()));
app.detail_cache.insert_commit_patches(repo.clone(), ready_sha, patches);
app.commit_diff_fetching.insert((repo, inflight_sha));
assert_eq!(
app.commit_diff_cache_counts(),
Some((1, 3, 1)),
"counts should expose ready, total, and in-flight commit diffs"
);
}
#[test]
fn cached_tab_restore_clears_unpersisted_commit_scope() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("o/r", 1);
app.detail_cache.insert_pr(detail);
app.per_tab_state.insert(
"o/r".to_owned(),
PerTabState {
detail_ref: Some(DetailRef { repo: "o/r".to_owned(), number: 1, kind: DetailKind::Pr }),
},
);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Files;
app.pr_detail_files_show_diff = true;
app.selected_commit = Some(0);
app.commits_cursor = 3;
app.restore_active_tab_state();
assert!(app.pr_detail.is_some(), "cached restore should still populate detail");
assert!(
app.selected_commit.is_none(),
"tab restore should not inherit an unpersisted commit scope"
);
assert_eq!(app.commits_cursor, 0, "tab restore should reset commit cursor");
}
#[test]
fn return_to_head_clears_scope() {
use crate::ui::pr_detail::tests::fixture_pr_detail_with_commits;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
app.pr_detail_selected_section = DetailSection::Commits;
app.pr_detail = Some(fixture_pr_detail_with_commits(2));
app.selected_commit = Some(0);
app.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('H'),
crossterm::event::KeyModifiers::NONE,
));
assert!(app.selected_commit.is_none(), "H must clear selected_commit");
assert!(app.flash.is_some(), "H must show a 'Returned to HEAD' flash message");
}
#[test]
fn force_push_evicts_stale_commit_patches() {
use chrono::Utc;
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::Detail;
let stale_sha = "deadbeef".repeat(5); let mut patches = std::collections::HashMap::new();
patches.insert("src/old.rs".to_owned(), Some("@@ -1 +1 @@\n+x".to_owned()));
app.detail_cache.insert_commit_patches("o/r".to_owned(), stale_sha.clone(), patches);
assert!(
app.detail_cache.get_commit_patches("o/r", &stale_sha).is_some(),
"patch entry must exist before prune"
);
let mut fresh_detail = make_pr_detail_for_app("o/r", 5);
fresh_detail.commits = vec![crate::github::detail::PrCommit {
sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
short_sha: "aaaaaaa".to_owned(),
headline: "new commit".to_owned(),
author: "dev".to_owned(),
committed_at: Utc::now(),
additions: 1,
deletions: 0,
changed_files: 1,
check_state: None,
}];
app.pr_detail = Some(make_pr_detail_for_app("o/r", 5));
app.selected_commit = Some(0);
app.handle_action(Action::PrDetailLoaded(Box::new(fresh_detail)));
assert!(
app.detail_cache.get_commit_patches("o/r", &stale_sha).is_none(),
"stale commit patches must be evicted after PrDetailLoaded with rewritten SHAs"
);
assert!(
app.selected_commit.is_none(),
"selected_commit must be reset to None on PrDetailLoaded"
);
}
#[test]
fn auto_refresh_action_dispatches_inbox_and_detail_refresh() {
let config = crate::config::Config { repos: vec!["o/r".to_owned()], ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
let detail = make_pr_detail_for_app("o/r", 11);
app.pr_detail = Some(detail);
app.focus = Focus::Detail;
app.fetching = true;
app.client = None;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
app.action_tx = Some(tx);
app.handle_action(Action::AutoRefresh);
assert_eq!(
app.detail_refreshing,
Some(("o/r".to_owned(), 11)),
"AutoRefresh in Detail focus must set detail_refreshing"
);
}