use gwm::agent_sessions::{AgentKind, AgentSession};
use gwm::tui::{draw, App, TaskKind};
use ratatui::{backend::TestBackend, Terminal};
use std::collections::BTreeMap;
use std::path::Path;
use std::time::{Duration, SystemTime};
use tempfile::TempDir;
fn repo() -> TempDir {
let dir = TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
repo.set_head("refs/heads/main").ok();
let sig = git2::Signature::now("gwm-test", "gwm@test").unwrap();
std::fs::write(dir.path().join("file.txt"), "seed").unwrap();
repo.index().unwrap().add_path(Path::new("file.txt")).unwrap();
repo.index().unwrap().write().unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
dir
}
fn session(i: usize) -> AgentSession {
AgentSession {
kind: AgentKind::ClaudeCode,
cwd: std::path::PathBuf::from("/x/elsewhere"),
last_activity: SystemTime::now() - Duration::from_secs(30),
ended: false,
id: format!("aaa-{i:02}"),
name: None,
}
}
fn app_with_open_prompt(dir: &TempDir, count: usize) -> App {
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let generation = app.tasks.request(TaskKind::AgentSessions).unwrap();
let pool: Vec<AgentSession> = (0..count).map(session).collect();
assert!(app.apply_agent_snapshot(generation, BTreeMap::new(), Some(pool), BTreeMap::new()));
app.open_agent_overlay();
app.open_agent_input();
assert_eq!(app.agent_input_candidates().len(), count, "the pool feeds the prompt");
app
}
#[test]
fn ci_checks_overlay_shows_its_own_hints_and_row_details() {
use gwm::github::{CheckOutcome, PrCheck};
use gwm::tui::state::detail_overlay::{ci_check_rows, DetailKind};
let dir = repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let checks = vec![PrCheck {
name: "test (ubuntu-latest)".into(),
outcome: CheckOutcome::Passing,
url: None,
workflow_name: Some("ci".into()),
started_at: Some("2026-07-24T14:51:06Z".into()),
completed_at: Some("2026-07-24T14:52:24Z".into()),
}];
let rows = ci_check_rows(&checks, SystemTime::now());
app.detail_overlay.open(DetailKind::CiChecks, "CI Checks".into(), rows);
app.view = gwm::tui::View::DetailOverlay;
let joined = draw_rows(&mut app, 100, 30).join("\n");
assert!(joined.contains("open"), "ci_checks verbs must be advertised: {joined}");
assert!(
joined.contains("filter"),
"ci_checks verbs must be advertised: {joined}"
);
assert!(
!joined.contains("attach"),
"the agents verbs must not leak into the CI overlay: {joined}"
);
assert!(
joined.contains("ci · 1m18s"),
"each row must carry workflow + duration: {joined}"
);
}
#[test]
fn ci_checks_long_name_truncates_and_keeps_the_detail_column() {
use gwm::github::{CheckOutcome, PrCheck};
use gwm::tui::state::detail_overlay::{ci_check_rows, DetailKind};
let dir = repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let checks = vec![PrCheck {
name: format!("test (windows-latest, {})", "x".repeat(90)),
outcome: CheckOutcome::Passing,
url: None,
workflow_name: Some("ci".into()),
started_at: Some("2026-07-24T14:51:06Z".into()),
completed_at: Some("2026-07-24T14:52:24Z".into()),
}];
let rows = ci_check_rows(&checks, SystemTime::now());
app.detail_overlay.open(DetailKind::CiChecks, "CI Checks".into(), rows);
app.view = gwm::tui::View::DetailOverlay;
let joined = draw_rows(&mut app, 100, 30).join("\n");
assert!(
joined.contains("ci · 1m18s"),
"the detail column survives a long check name: {joined}"
);
assert!(
joined.contains('…'),
"the long name truncates with an ellipsis: {joined}"
);
}
#[test]
fn ci_checks_narrow_terminal_bounds_the_detail_column() {
use gwm::github::{CheckOutcome, PrCheck};
use gwm::tui::state::detail_overlay::{ci_check_rows, DetailKind};
let dir = repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let checks = vec![PrCheck {
name: "build".into(),
outcome: CheckOutcome::Passing,
url: None,
workflow_name: Some(format!("release-{}", "w".repeat(80))),
started_at: Some("2026-07-24T14:51:06Z".into()),
completed_at: Some("2026-07-24T14:52:24Z".into()),
}];
let rows = ci_check_rows(&checks, SystemTime::now());
app.detail_overlay.open(DetailKind::CiChecks, "CI Checks".into(), rows);
app.view = gwm::tui::View::DetailOverlay;
let joined = draw_rows(&mut app, 60, 20).join("\n");
assert!(
joined.contains("build"),
"the check name keeps its reserved width: {joined}"
);
assert!(
joined.contains("1m18s"),
"the duration tail survives the bounded detail column: {joined}"
);
assert!(
joined.contains('…'),
"the oversized detail column truncates with an ellipsis: {joined}"
);
}
#[test]
fn ci_checks_filter_scrollbar_tracks_the_listing_not_the_query() {
use gwm::github::{CheckOutcome, PrCheck};
use gwm::tui::state::detail_overlay::{ci_check_rows, DetailKind};
let dir = repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let checks: Vec<PrCheck> = (0..25)
.map(|i| PrCheck {
name: format!("check-{i:02}"),
outcome: CheckOutcome::Passing,
url: None,
workflow_name: None,
started_at: None,
completed_at: None,
})
.collect();
let rows = ci_check_rows(&checks, SystemTime::now());
app.detail_overlay.open(DetailKind::CiChecks, "CI Checks".into(), rows);
app.view = gwm::tui::View::DetailOverlay;
app.ci_input_open();
let rows = draw_rows(&mut app, 100, 26);
assert!(
rows.iter().any(|r| r.contains('█')),
"an overflowing filtered list must show the scrollbar thumb"
);
let query_row = rows
.iter()
.find(|r| r.contains("filter:"))
.expect("the query line is on screen");
assert!(
!query_row.contains('█'),
"the scrollbar must not overlap the query line: {query_row}"
);
}
fn draw_rows(app: &mut App, width: u16, height: u16) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, app)).unwrap();
let cells: Vec<String> = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect();
cells.chunks(width as usize).map(|row| row.concat()).collect()
}
fn corner_rows(rows: &[String]) -> Vec<usize> {
rows
.iter()
.enumerate()
.filter(|(_, r)| r.contains('╰') || r.contains('└'))
.map(|(i, _)| i)
.collect()
}
#[test]
fn attach_prompt_frame_does_not_resize_while_typing() {
let dir = repo();
let mut app = app_with_open_prompt(&dir, 8);
let before = draw_rows(&mut app, 100, 32);
assert!(
before.iter().any(|r| r.contains("Attach a session")),
"the prompt is on screen"
);
app.agent_input_push('z');
assert_eq!(app.agent_input_candidates().len(), 0);
let after = draw_rows(&mut app, 100, 32);
assert_eq!(
corner_rows(&before),
corner_rows(&after),
"the modal frame must keep its height when the filter empties the list"
);
}
#[test]
fn attach_prompt_shows_a_scrollbar_when_candidates_overflow() {
let dir = repo();
let mut app = app_with_open_prompt(&dir, 40);
let rows = draw_rows(&mut app, 100, 24);
assert!(
rows.iter().any(|r| r.contains('█')),
"an overflowing candidate list must render a scrollbar thumb"
);
}
#[test]
fn attach_prompt_survives_a_tiny_terminal() {
let dir = repo();
let mut app = app_with_open_prompt(&dir, 10);
let _ = draw_rows(&mut app, 100, 8);
let _ = draw_rows(&mut app, 100, 5);
}
#[test]
fn detail_overlay_survives_a_tiny_terminal() {
use gwm::agent_sessions::WorktreeAgents;
let dir = repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
let generation = app.tasks.request(TaskKind::AgentSessions).unwrap();
let path = app.worktrees[0].path.to_string_lossy().to_string();
let mut map = BTreeMap::new();
map.insert(
path,
WorktreeAgents {
sessions: (0..6).map(session).collect(),
},
);
assert!(app.apply_agent_snapshot(generation, map, None, BTreeMap::new()));
app.open_agent_overlay();
let _ = draw_rows(&mut app, 100, 6);
let _ = draw_rows(&mut app, 100, 5);
}
#[test]
fn attach_prompt_window_is_capped_on_tall_terminals() {
let dir = repo();
let mut app = app_with_open_prompt(&dir, 40);
let rows = draw_rows(&mut app, 100, 40);
let shown = rows.iter().filter(|r| r.contains("aaa-")).count();
assert!(shown <= 10, "the candidate window is capped at 10 rows, got {shown}");
}