src/tui/mod.rs
Ref: Size: 45.6 KiB
mod events;
mod state;
mod widgets;
use std::io::stdout;
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use ratatui::prelude::*;
use crate::error::Error;
use crate::state as app_state;
use self::events::run_loop;
use self::state::App;
pub fn run(repo: &Repository) -> Result<(), Error> {
let issues = app_state::list_issues(repo)?;
let patches = app_state::list_patches(repo)?;
let mut app = App::new(issues, patches);
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let result = run_loop(&mut terminal, &mut app, repo);
terminal::disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
result
}
#[cfg(test)]
mod tests {
use super::state::*;
use super::widgets::*;
use crate::event::{Action, Author, ReviewVerdict};
use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};
use git2::Oid;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::prelude::*;
use ratatui::widgets::ListState;
fn make_author() -> Author {
Author {
name: "test".into(),
email: "test@test.com".into(),
}
}
fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState {
IssueState {
id: id.into(),
title: title.into(),
body: String::new(),
status,
close_reason: None,
closed_by: None,
labels: vec![],
assignees: vec![],
comments: vec![],
linked_commits: vec![],
created_at: String::new(),
last_updated: String::new(),
author: make_author(),
relates_to: None,
}
}
fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState {
PatchState {
id: id.into(),
title: title.into(),
body: String::new(),
status,
base_ref: "main".into(),
fixes: None,
branch: format!("feature/{}", id),
base_commit: None,
comments: vec![],
inline_comments: vec![],
reviews: vec![],
revisions: vec![],
created_at: String::new(),
last_updated: String::new(),
author: make_author(),
}
}
fn test_app() -> App {
let issues = vec![
make_issue("i1", "Fix login bug", IssueStatus::Open),
make_issue("i2", "Add dashboard feature", IssueStatus::Open),
make_issue("i3", "Old closed issue", IssueStatus::Closed),
];
let patches = vec![
make_patch("p1", "Login fix patch", PatchStatus::Open),
make_patch("p2", "Dashboard patch", PatchStatus::Closed),
make_patch("p3", "Merged feature", PatchStatus::Merged),
];
App::new(issues, patches)
}
// T010: visible_issues filters by search_query (case-insensitive)
#[test]
fn test_visible_issues_text_filter() {
let mut app = test_app();
app.status_filter = StatusFilter::All;
app.search_query = "login".into();
let visible = app.visible_issues();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].title, "Fix login bug");
}
// T012: visible_issues returns all status-matching items when search_query is empty
#[test]
fn test_visible_issues_no_text_filter() {
let mut app = test_app();
app.status_filter = StatusFilter::All;
app.search_query.clear();
let visible = app.visible_issues();
assert_eq!(visible.len(), 3);
}
// T019: StatusFilter::next() cycles Open -> All -> Closed -> Open
#[test]
fn test_status_filter_cycle() {
assert_eq!(StatusFilter::Open.next(), StatusFilter::All);
assert_eq!(StatusFilter::All.next(), StatusFilter::Closed);
assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open);
}
// T020: visible_issues returns only closed when status_filter is Closed
#[test]
fn test_visible_issues_closed_filter() {
let mut app = test_app();
app.status_filter = StatusFilter::Closed;
let visible = app.visible_issues();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].title, "Old closed issue");
}
// T025: combined status + text filter
#[test]
fn test_combined_filters() {
let mut app = test_app();
app.status_filter = StatusFilter::Open;
app.search_query = "login".into();
let visible = app.visible_issues();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].title, "Fix login bug");
// Same query with Closed filter should return nothing
app.status_filter = StatusFilter::Closed;
let visible = app.visible_issues();
assert_eq!(visible.len(), 0);
}
// T026: Escape clears text filter but preserves status_filter
#[test]
fn test_escape_clears_text_preserves_status() {
let mut app = test_app();
app.status_filter = StatusFilter::Closed;
app.input_mode = InputMode::Search;
app.search_query = "some query".into();
// Simulate Escape
app.input_mode = InputMode::Normal;
app.search_query.clear();
assert_eq!(app.status_filter, StatusFilter::Closed);
assert_eq!(app.input_mode, InputMode::Normal);
assert!(app.search_query.is_empty());
}
#[test]
fn test_create_title_mode_clears_on_escape() {
let mut app = test_app();
app.input_mode = InputMode::CreateTitle;
app.input_buf = "partial title".into();
// Simulate Escape
app.input_mode = InputMode::Normal;
app.input_buf.clear();
assert_eq!(app.input_mode, InputMode::Normal);
assert!(app.input_buf.is_empty());
}
#[test]
fn test_create_title_transitions_to_body() {
let mut app = test_app();
app.input_mode = InputMode::CreateTitle;
app.input_buf = "My new issue".into();
// Simulate Enter with non-empty title
let title = app.input_buf.trim().to_string();
assert!(!title.is_empty());
app.create_title = title;
app.input_buf.clear();
app.input_mode = InputMode::CreateBody;
assert_eq!(app.input_mode, InputMode::CreateBody);
assert_eq!(app.create_title, "My new issue");
assert!(app.input_buf.is_empty());
}
#[test]
fn test_empty_title_dismissed() {
let mut app = test_app();
app.input_mode = InputMode::CreateTitle;
app.input_buf = " ".into();
// Simulate Enter with whitespace-only title
let title = app.input_buf.trim().to_string();
if title.is_empty() {
app.input_mode = InputMode::Normal;
app.input_buf.clear();
}
assert_eq!(app.input_mode, InputMode::Normal);
}
// ── Commit browser test helpers ─────────────────────────────────────
fn test_author() -> Author {
Author {
name: "Test User".to_string(),
email: "test@example.com".to_string(),
}
}
fn make_test_issues(n: usize) -> Vec<IssueState> {
(0..n)
.map(|i| IssueState {
id: format!("{:08x}", i),
title: format!("Issue {}", i),
body: format!("Body for issue {}", i),
status: if i % 2 == 0 {
IssueStatus::Open
} else {
IssueStatus::Closed
},
close_reason: if i % 2 == 1 {
Some("done".to_string())
} else {
None
},
closed_by: None,
labels: vec![],
assignees: vec![],
comments: Vec::new(),
linked_commits: Vec::new(),
created_at: "2026-01-01T00:00:00Z".to_string(),
last_updated: "2026-01-01T00:00:00Z".to_string(),
author: test_author(),
relates_to: None,
})
.collect()
}
fn make_test_patches(n: usize) -> Vec<PatchState> {
(0..n)
.map(|i| PatchState {
id: format!("p{:07x}", i),
title: format!("Patch {}", i),
body: format!("Body for patch {}", i),
status: if i % 2 == 0 {
PatchStatus::Open
} else {
PatchStatus::Closed
},
base_ref: "main".to_string(),
fixes: None,
branch: format!("feature/p{:07x}", i),
base_commit: None,
comments: Vec::new(),
inline_comments: Vec::new(),
reviews: Vec::new(),
revisions: Vec::new(),
created_at: "2026-01-01T00:00:00Z".to_string(),
last_updated: "2026-01-01T00:00:00Z".to_string(),
author: test_author(),
})
.collect()
}
fn make_app(issues: usize, patches: usize) -> App {
App::new(make_test_issues(issues), make_test_patches(patches))
}
fn render_app(app: &mut App) -> Buffer {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| ui(frame, app, None)).unwrap();
terminal.backend().buffer().clone()
}
fn buffer_to_string(buf: &Buffer) -> String {
let area = buf.area;
let mut s = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = buf.cell((x, y)).unwrap();
s.push_str(cell.symbol());
}
s.push('\n');
}
s
}
fn assert_buffer_contains(buf: &Buffer, expected: &str) {
let text = buffer_to_string(buf);
assert!(
text.contains(expected),
"expected buffer to contain {:?}, but it was not found in:\n{}",
expected,
text
);
}
/// Create sample event history for testing commit browser
fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> {
let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
vec![
(
oid1,
crate::event::Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: test_author(),
action: Action::IssueOpen {
title: "Test Issue".to_string(),
body: "This is the body".to_string(),
relates_to: None,
},
clock: 0,
},
),
(
oid2,
crate::event::Event {
timestamp: "2026-01-02T00:00:00Z".to_string(),
author: Author {
name: "Other User".to_string(),
email: "other@example.com".to_string(),
},
action: Action::IssueComment {
body: "A comment on the issue".to_string(),
},
clock: 0,
},
),
(
oid3,
crate::event::Event {
timestamp: "2026-01-03T00:00:00Z".to_string(),
author: test_author(),
action: Action::IssueClose {
reason: Some("fixed".to_string()),
},
clock: 0,
},
),
]
}
// ── action_type_label tests ──────────────────────────────────────────
#[test]
fn test_action_type_label_issue_open() {
let action = Action::IssueOpen {
title: "t".to_string(),
body: "b".to_string(),
relates_to: None,
};
assert_eq!(action_type_label(&action), "Issue Open");
}
#[test]
fn test_action_type_label_issue_comment() {
let action = Action::IssueComment {
body: "b".to_string(),
};
assert_eq!(action_type_label(&action), "Issue Comment");
}
#[test]
fn test_action_type_label_issue_close() {
let action = Action::IssueClose { reason: None };
assert_eq!(action_type_label(&action), "Issue Close");
}
#[test]
fn test_action_type_label_issue_reopen() {
assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen");
}
#[test]
fn test_action_type_label_patch_create() {
let action = Action::PatchCreate {
title: "t".to_string(),
body: "b".to_string(),
base_ref: "main".to_string(),
branch: "feature/test".to_string(),
fixes: None,
commit: "abc123".to_string(),
tree: "def456".to_string(),
base_commit: None,
};
assert_eq!(action_type_label(&action), "Patch Create");
}
#[test]
fn test_action_type_label_patch_review() {
let action = Action::PatchReview {
verdict: ReviewVerdict::Approve,
body: "lgtm".to_string(),
revision: 1,
};
assert_eq!(action_type_label(&action), "Patch Review");
}
#[test]
fn test_action_type_label_inline_comment() {
let action = Action::PatchInlineComment {
file: "src/main.rs".to_string(),
line: 42,
body: "nit".to_string(),
revision: 1,
};
assert_eq!(action_type_label(&action), "Inline Comment");
}
#[test]
fn test_action_type_label_merge() {
assert_eq!(action_type_label(&Action::Merge), "Merge");
}
// ── format_event_detail tests ────────────────────────────────────────
#[test]
fn test_format_event_detail_issue_open() {
let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let event = crate::event::Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: test_author(),
action: Action::IssueOpen {
title: "My Issue".to_string(),
body: "Description here".to_string(),
relates_to: None,
},
clock: 0,
};
let detail = format_event_detail(&oid, &event);
assert!(detail.contains("aaaaaaa"));
assert!(detail.contains("Test User <test@example.com>"));
assert!(detail.contains("2026-01-01T00:00:00Z"));
assert!(detail.contains("Issue Open"));
assert!(detail.contains("Title: My Issue"));
assert!(detail.contains("Description here"));
}
#[test]
fn test_format_event_detail_issue_close_with_reason() {
let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
let event = crate::event::Event {
timestamp: "2026-02-01T00:00:00Z".to_string(),
author: test_author(),
action: Action::IssueClose {
reason: Some("resolved".to_string()),
},
clock: 0,
};
let detail = format_event_detail(&oid, &event);
assert!(detail.contains("Issue Close"));
assert!(detail.contains("Reason: resolved"));
}
#[test]
fn test_format_event_detail_patch_review() {
let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
let event = crate::event::Event {
timestamp: "2026-03-01T00:00:00Z".to_string(),
author: test_author(),
action: Action::PatchReview {
verdict: ReviewVerdict::Approve,
body: "Looks good!".to_string(),
revision: 1,
},
clock: 0,
};
let detail = format_event_detail(&oid, &event);
assert!(detail.contains("Patch Review"));
assert!(detail.contains("approve"));
assert!(detail.contains("Looks good!"));
}
#[test]
fn test_format_event_detail_short_oid() {
let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap();
let event = crate::event::Event {
timestamp: "2026-01-01T00:00:00Z".to_string(),
author: test_author(),
action: Action::IssueReopen,
clock: 0,
};
let detail = format_event_detail(&oid, &event);
assert!(detail.contains("1234567"));
assert!(detail.contains("Commit: 1234567\n"));
}
// ── handle_key tests for 'c' key ─────────────────────────────────────
#[test]
fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() {
let mut app = make_app(3, 0);
app.pane = Pane::Detail;
app.list_state.select(Some(0));
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::OpenCommitBrowser);
}
#[test]
fn test_handle_key_c_in_item_list_pane_is_noop() {
let mut app = make_app(3, 0);
app.pane = Pane::ItemList;
app.list_state.select(Some(0));
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
}
#[test]
fn test_handle_key_c_no_selection_is_noop() {
let mut app = make_app(0, 0);
app.pane = Pane::Detail;
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
}
#[test]
fn test_handle_key_ctrl_c_still_quits() {
let mut app = make_app(3, 0);
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(result, KeyAction::Quit);
}
// ── CommitList navigation tests ──────────────────────────────────────
#[test]
fn test_commit_list_navigate_down() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitList;
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.event_list_state.selected(), Some(1));
}
#[test]
fn test_commit_list_navigate_up() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(2));
app.mode = ViewMode::CommitList;
app.handle_key(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.event_list_state.selected(), Some(1));
}
#[test]
fn test_commit_list_navigate_clamp_bottom() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(2));
app.mode = ViewMode::CommitList;
app.handle_key(
crossterm::event::KeyCode::Down,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.event_list_state.selected(), Some(2));
}
#[test]
fn test_commit_list_navigate_clamp_top() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitList;
app.handle_key(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.event_list_state.selected(), Some(0));
}
#[test]
fn test_commit_list_escape_returns_to_details() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(1));
app.mode = ViewMode::CommitList;
let result = app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::Details);
assert!(app.event_history.is_empty());
assert_eq!(app.event_list_state.selected(), None);
}
#[test]
fn test_commit_list_q_quits() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitList;
let result = app.handle_key(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Quit);
}
// ── CommitDetail tests ───────────────────────────────────────────────
#[test]
fn test_commit_list_enter_opens_detail() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(1));
app.mode = ViewMode::CommitList;
let result = app.handle_key(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::CommitDetail);
assert_eq!(app.scroll, 0);
}
#[test]
fn test_commit_list_enter_no_selection_stays() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state = ListState::default();
app.mode = ViewMode::CommitList;
app.handle_key(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.mode, ViewMode::CommitList);
}
#[test]
fn test_commit_detail_escape_returns_to_list() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitDetail;
app.scroll = 5;
let result = app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::CommitList);
assert_eq!(app.scroll, 0);
assert_eq!(app.event_history.len(), 3);
}
#[test]
fn test_commit_detail_scroll() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitDetail;
app.scroll = 0;
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 1);
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 2);
app.handle_key(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 1);
}
#[test]
fn test_commit_detail_page_scroll() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitDetail;
app.scroll = 0;
app.handle_key(
crossterm::event::KeyCode::PageDown,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 20);
app.handle_key(
crossterm::event::KeyCode::PageUp,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 0);
}
#[test]
fn test_commit_detail_q_quits() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitDetail;
let result = app.handle_key(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Quit);
}
// ── Guard tests ──────────────────────────────────────────────────────
#[test]
fn test_c_ignored_in_commit_list_mode() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitList;
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::CommitList);
}
#[test]
fn test_c_ignored_in_commit_detail_mode() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitDetail;
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::CommitDetail);
}
// ── handle_key basic tests ───────────────────────────────────────────
#[test]
fn test_handle_key_quit() {
let mut app = make_app(3, 3);
assert_eq!(
app.handle_key(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::empty()
),
KeyAction::Quit
);
}
#[test]
fn test_handle_key_quit_esc() {
let mut app = make_app(3, 3);
assert_eq!(
app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty()
),
KeyAction::Quit
);
}
#[test]
fn test_handle_key_quit_ctrl_c() {
let mut app = make_app(3, 3);
assert_eq!(
app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::CONTROL
),
KeyAction::Quit
);
}
#[test]
fn test_handle_key_pane_toggle() {
let mut app = make_app(3, 3);
assert_eq!(app.pane, Pane::ItemList);
app.handle_key(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.pane, Pane::Detail);
app.handle_key(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.pane, Pane::ItemList);
}
#[test]
fn test_scroll_in_detail_pane() {
let mut app = make_app(3, 0);
app.list_state.select(Some(0));
app.pane = Pane::Detail;
app.scroll = 0;
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 1);
assert_eq!(app.list_state.selected(), Some(0));
app.handle_key(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 0);
}
#[test]
fn test_handle_key_reload() {
let mut app = make_app(3, 3);
assert_eq!(
app.handle_key(
crossterm::event::KeyCode::Char('r'),
crossterm::event::KeyModifiers::empty()
),
KeyAction::Reload
);
}
// ── selected_ref_name tests ──────────────────────────────────────────
#[test]
fn test_selected_ref_name_issues() {
let app = make_app(3, 0);
let ref_name = app.selected_ref_name();
assert_eq!(
ref_name,
Some("refs/collab/issues/00000000".to_string())
);
}
#[test]
fn test_selected_ref_name_none_when_empty() {
let app = make_app(0, 0);
assert_eq!(app.selected_ref_name(), None);
}
// ── Render tests ─────────────────────────────────────────────────────
#[test]
fn test_render_issues_tab() {
let mut app = make_app(3, 2);
app.status_filter = StatusFilter::All;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Issues");
assert_buffer_contains(&buf, "00000000");
assert_buffer_contains(&buf, "00000001");
assert_buffer_contains(&buf, "00000002");
assert_buffer_contains(&buf, "Issue 0");
}
#[test]
fn test_render_empty_state() {
let mut app = make_app(0, 0);
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "No matches for current filter.");
}
#[test]
fn test_render_footer_keys() {
let mut app = make_app(3, 3);
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "j/k:navigate");
assert_buffer_contains(&buf, "Tab:pane");
assert_buffer_contains(&buf, "c:events");
}
#[test]
fn test_render_commit_list() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitList;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Event History");
assert_buffer_contains(&buf, "Issue Open");
assert_buffer_contains(&buf, "Issue Comment");
assert_buffer_contains(&buf, "Issue Close");
}
#[test]
fn test_render_commit_detail() {
let mut app = make_app(3, 0);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitDetail;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Event Detail");
assert_buffer_contains(&buf, "aaaaaaa");
assert_buffer_contains(&buf, "Test User");
assert_buffer_contains(&buf, "Issue Open");
}
#[test]
fn test_render_commit_list_footer() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitList;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Esc:back");
}
#[test]
fn test_render_commit_detail_footer() {
let mut app = make_app(3, 0);
app.mode = ViewMode::CommitDetail;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Esc:back");
assert_buffer_contains(&buf, "j/k:scroll");
}
#[test]
fn test_render_small_terminal() {
let mut app = make_app(3, 3);
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| ui(frame, &mut app, None)).unwrap();
}
// ── Integration: full browse flow ────────────────────────────────────
#[test]
fn test_full_commit_browse_flow() {
let mut app = make_app(3, 0);
app.pane = Pane::Detail;
app.list_state.select(Some(0));
let action = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(action, KeyAction::OpenCommitBrowser);
app.event_history = make_test_event_history();
app.event_list_state.select(Some(0));
app.mode = ViewMode::CommitList;
app.scroll = 0;
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.event_list_state.selected(), Some(1));
app.handle_key(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.mode, ViewMode::CommitDetail);
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.scroll, 1);
app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.mode, ViewMode::CommitList);
assert_eq!(app.scroll, 0);
app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.mode, ViewMode::Details);
assert!(app.event_history.is_empty());
}
// ── Status message render test ───────────────────────────────────────
#[test]
fn test_render_status_message() {
let mut app = make_app(3, 0);
app.status_msg = Some("Error loading events: ref not found".to_string());
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Error loading events");
}
// ── Patch detail view tests ─────────────────────────────────────────
fn make_patch_with_revisions() -> PatchState {
use crate::state::Revision;
PatchState {
id: "deadbeef".into(),
title: "Fix the thing".into(),
body: "Detailed description".into(),
status: PatchStatus::Open,
base_ref: "main".into(),
fixes: Some("i1".into()),
branch: "feature/fix-thing".into(),
base_commit: None,
comments: vec![crate::state::Comment {
author: make_author(),
body: "Thread comment".into(),
timestamp: "2026-01-05T00:00:00Z".into(),
commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
}],
inline_comments: vec![crate::state::InlineComment {
author: make_author(),
file: "src/main.rs".into(),
line: 42,
body: "Nit: rename this".into(),
timestamp: "2026-01-03T00:00:00Z".into(),
revision: Some(1),
}],
reviews: vec![crate::state::Review {
author: make_author(),
verdict: ReviewVerdict::Approve,
body: "LGTM".into(),
timestamp: "2026-01-04T00:00:00Z".into(),
revision: Some(2),
}],
revisions: vec![
Revision {
number: 1,
commit: "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111".into(),
tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(),
body: None,
timestamp: "2026-01-01T00:00:00Z".into(),
},
Revision {
number: 2,
commit: "aaaa2222aaaa2222aaaa2222aaaa2222aaaa2222".into(),
tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(),
body: Some("Addressed review comments".into()),
timestamp: "2026-01-02T00:00:00Z".into(),
},
],
created_at: "2026-01-01T00:00:00Z".into(),
last_updated: "2026-01-04T00:00:00Z".into(),
author: make_author(),
}
}
#[test]
fn test_p_key_opens_patch_detail_when_linked_patch_exists() {
let mut app = test_app();
// Issue i1 has a linked patch (p1 has fixes=None, so we need to set it)
app.patches[0].fixes = Some("i1".into());
app.pane = Pane::Detail;
app.list_state.select(Some(0)); // selects issue i1
let result = app.handle_key(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::OpenPatchDetail);
}
#[test]
fn test_p_key_noop_when_no_linked_patch() {
let mut app = test_app();
// No patches fix any issues by default
app.pane = Pane::Detail;
app.list_state.select(Some(0));
let result = app.handle_key(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
}
#[test]
fn test_p_key_noop_in_item_list_pane() {
let mut app = test_app();
app.patches[0].fixes = Some("i1".into());
app.pane = Pane::ItemList;
app.list_state.select(Some(0));
let result = app.handle_key(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
}
#[test]
fn test_patch_detail_esc_returns_to_details() {
let mut app = test_app();
app.current_patch = Some(make_patch_with_revisions());
app.mode = ViewMode::PatchDetail;
app.patch_scroll = 5;
app.patch_revision_idx = 1;
app.patch_interdiff_mode = true;
app.patch_diff = "some diff".into();
let result = app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.mode, ViewMode::Details);
assert!(app.current_patch.is_none());
assert!(app.patch_diff.is_empty());
assert_eq!(app.patch_scroll, 0);
assert_eq!(app.patch_revision_idx, 0);
assert!(!app.patch_interdiff_mode);
}
#[test]
fn test_patch_detail_q_quits() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
let result = app.handle_key(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Quit);
}
#[test]
fn test_patch_detail_scroll() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
app.current_patch = Some(make_patch_with_revisions());
app.patch_scroll = 0;
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 1);
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 2);
app.handle_key(
crossterm::event::KeyCode::Char('k'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 1);
}
#[test]
fn test_patch_detail_page_scroll() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
app.patch_scroll = 0;
app.handle_key(
crossterm::event::KeyCode::PageDown,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 20);
app.handle_key(
crossterm::event::KeyCode::PageUp,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 0);
}
#[test]
fn test_patch_detail_revision_navigation() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
app.current_patch = Some(make_patch_with_revisions());
app.patch_revision_idx = 0;
// Navigate forward
let result = app.handle_key(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Reload);
assert_eq!(app.patch_revision_idx, 1);
assert_eq!(app.patch_scroll, 0);
// Can't go past last revision
let result = app.handle_key(
crossterm::event::KeyCode::Char(']'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.patch_revision_idx, 1);
// Navigate backward
let result = app.handle_key(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Reload);
assert_eq!(app.patch_revision_idx, 0);
// Can't go below 0
let result = app.handle_key(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Continue);
assert_eq!(app.patch_revision_idx, 0);
}
#[test]
fn test_patch_detail_interdiff_toggle() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
app.current_patch = Some(make_patch_with_revisions());
app.patch_interdiff_mode = false;
let result = app.handle_key(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Reload);
assert!(app.patch_interdiff_mode);
assert_eq!(app.patch_scroll, 0);
let result = app.handle_key(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(result, KeyAction::Reload);
assert!(!app.patch_interdiff_mode);
}
#[test]
fn test_render_patch_detail() {
let mut app = make_app(3, 0);
app.mode = ViewMode::PatchDetail;
app.current_patch = Some(make_patch_with_revisions());
app.patch_revision_idx = 1;
app.patch_diff = "+added line\n-removed line\n context".into();
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Patch Detail");
assert_buffer_contains(&buf, "Fix the thing");
assert_buffer_contains(&buf, "deadbeef");
assert_buffer_contains(&buf, "feature/fix-thing");
}
#[test]
fn test_render_patch_detail_shows_reviews() {
let mut app = make_app(3, 0);
app.mode = ViewMode::PatchDetail;
app.current_patch = Some(make_patch_with_revisions());
app.patch_revision_idx = 1;
app.patch_diff = String::new();
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Reviews");
assert_buffer_contains(&buf, "approve");
assert_buffer_contains(&buf, "LGTM");
}
#[test]
fn test_render_patch_detail_footer() {
let mut app = make_app(3, 0);
app.mode = ViewMode::PatchDetail;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "Esc:back");
assert_buffer_contains(&buf, "[/]:revision");
// "d:interdiff" may be truncated at 80 cols, check prefix
assert_buffer_contains(&buf, "d:inter");
}
#[test]
fn test_render_patch_detail_no_patch_loaded() {
let mut app = make_app(3, 0);
app.mode = ViewMode::PatchDetail;
app.current_patch = None;
let buf = render_app(&mut app);
assert_buffer_contains(&buf, "No patch loaded");
}
#[test]
fn test_patch_detail_ctrl_c_quits() {
let mut app = test_app();
app.mode = ViewMode::PatchDetail;
let result = app.handle_key(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(result, KeyAction::Quit);
}
#[test]
fn test_full_patch_detail_flow() {
let mut app = test_app();
app.patches[0].fixes = Some("i1".into());
app.pane = Pane::Detail;
app.list_state.select(Some(0));
// Press p to open patch detail
let action = app.handle_key(
crossterm::event::KeyCode::Char('p'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(action, KeyAction::OpenPatchDetail);
// Simulate what events.rs does
app.current_patch = Some(make_patch_with_revisions());
app.patch_revision_idx = 1;
app.patch_diff = "diff content".into();
app.mode = ViewMode::PatchDetail;
// Navigate revisions
app.handle_key(
crossterm::event::KeyCode::Char('['),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_revision_idx, 0);
// Toggle interdiff
app.handle_key(
crossterm::event::KeyCode::Char('d'),
crossterm::event::KeyModifiers::empty(),
);
assert!(app.patch_interdiff_mode);
// Scroll
app.handle_key(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.patch_scroll, 1);
// Escape back
app.handle_key(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(app.mode, ViewMode::Details);
assert!(app.current_patch.is_none());
}
#[test]
fn test_linked_patch_for_selected() {
let mut app = test_app();
app.patches[0].fixes = Some("i1".into());
app.list_state.select(Some(0));
let patch = app.linked_patch_for_selected();
assert!(patch.is_some());
assert_eq!(patch.unwrap().title, "Login fix patch");
// Second issue has no linked patch
app.list_state.select(Some(1));
assert!(app.linked_patch_for_selected().is_none());
}
}