mod common;
use common::init_repo;
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{draw, App, LinkTarget, TaskKind, View};
use gwm::worktree::{BranchStatus, WorktreeInfo};
use ratatui::{backend::TestBackend, buffer::Buffer, Terminal};
use std::path::PathBuf;
const TERM_W: u16 = 100;
const TERM_H: u16 = 40;
fn make_app() -> (tempfile::TempDir, App) {
let (dir, _) = init_repo();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
app.sidebar.open = false;
(dir, app)
}
fn deletable_worktree(name: &str) -> WorktreeInfo {
WorktreeInfo {
name: name.into(),
id: name.into(),
path: PathBuf::from(format!("/tmp/gwm-test/{}", name)),
branch: Some(format!("feat/#235-{}", name)),
head: Some("0123456789abcdef0123456789abcdef01234567".into()),
is_main: false,
is_locked: false,
is_prunable: false,
status: BranchStatus::default(),
link: gwm::github::BranchLink::empty(),
issue_state: None,
pr_state: None,
age: None,
}
}
fn render(app: &mut App) -> Buffer {
let backend = TestBackend::new(TERM_W, TERM_H);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, app)).unwrap();
terminal.backend().buffer().clone()
}
fn row_strings(buf: &Buffer) -> Vec<String> {
let area = *buf.area();
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(area.x + x, area.y + y)].symbol())
.collect::<String>()
})
.collect()
}
fn buffer_contains(buf: &Buffer, needle: &str) -> bool {
row_strings(buf).iter().any(|row| row.contains(needle))
}
fn assert_present(buf: &Buffer, needle: &str, what: &str) {
assert!(
buffer_contains(buf, needle),
"{what}: expected {needle:?} to be rendered (not clipped) — buffer rows:\n{}",
row_strings(buf).join("\n")
);
}
#[test]
fn worktrees_table_header_labels_the_issue_pr_badge_column() {
let (_dir, mut app) = make_app();
let buf = render(&mut app);
assert_present(&buf, "I/P", "issue/PR badge column header");
assert_present(&buf, "NAME", "name column header");
assert_present(&buf, "BRANCH", "branch column header");
}
#[test]
fn help_modal_renders_title_and_close_hint() {
let (_dir, mut app) = make_app();
app.enter_help();
assert_eq!(app.view, View::Help);
let buf = render(&mut app);
assert_present(&buf, "Keybindings", "help title");
assert_present(&buf, "quit", "help quit entry");
}
#[test]
fn help_modal_keeps_title_and_footer_fixed_while_body_scrolls() {
let (_dir, mut app) = make_app();
app.enter_help();
app.help_scroll = u16::MAX;
let backend = TestBackend::new(100, 18);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
assert_present(&buf, "Keybindings", "help title stays fixed at the top");
assert_present(&buf, "close", "help footer hint stays fixed at the bottom");
}
#[test]
fn create_modal_renders_title_fields_and_buttons() {
let (_dir, mut app) = make_app();
app.enter_create();
assert_eq!(app.view, View::Create);
let buf = render(&mut app);
assert_present(&buf, "New Worktree", "create title");
assert_present(&buf, "Branch", "create branch preview label");
assert_present(&buf, "Issue", "create issue field label");
assert_present(&buf, "Desc", "create desc field label");
assert_present(&buf, "Create", "create button");
assert_present(&buf, "Cancel", "cancel button");
}
#[test]
fn create_modal_renders_loader_while_create_is_in_flight() {
let (_dir, mut app) = make_app();
app.enter_create();
app.tasks.request(TaskKind::CreateWorktree).unwrap();
let buf = render(&mut app);
assert_present(&buf, "New Worktree", "create title");
assert_present(&buf, "creating worktree", "create loader label");
}
#[test]
fn create_modal_renders_create_failure_after_async_create_fails() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_failure = Some("branch already exists".into());
let buf = render(&mut app);
assert_present(&buf, "create failed", "create failure label");
assert_present(&buf, "branch already exists", "create failure detail");
assert_present(&buf, "Cancel", "cancel button after failure");
}
#[test]
fn confirm_modal_renders_title_target_and_buttons() {
let (_dir, mut app) = make_app();
app.worktrees.push(deletable_worktree("feat-235-net"));
app.list_state.select(Some(app.worktrees.len() - 1));
app.view = View::Confirm;
let buf = render(&mut app);
assert_present(&buf, "Delete Worktree", "confirm title");
assert_present(&buf, "Path", "confirm detail-grid Path label");
assert_present(&buf, "Delete Branch", "confirm delete-branch toggle label");
assert_present(&buf, "Confirm", "confirm button");
assert_present(&buf, "Cancel", "cancel button");
}
#[test]
fn confirm_modal_delete_branch_row_uses_the_live_toggle_chord() {
let (_dir, mut app) = make_app();
app.worktrees.push(deletable_worktree("feat-290-togglekey"));
app.list_state.select(Some(app.worktrees.len() - 1));
app.view = View::Confirm;
let buf = render(&mut app);
let row = row_strings(&buf)
.into_iter()
.find(|r| r.contains("Delete Branch"))
.expect("a Delete Branch row");
assert!(
row.contains(" D "),
"delete-branch row must show the live `D` chord chip: {row:?}"
);
assert!(
!row.contains(" p "),
"stale `p` chip must be gone from the delete-branch row: {row:?}"
);
}
#[test]
fn confirm_modal_renders_delete_loader_while_delete_is_in_flight() {
let (_dir, mut app) = make_app();
app.worktrees.push(deletable_worktree("feat-257-loader"));
app.list_state.select(Some(app.worktrees.len() - 1));
app.view = View::Confirm;
app.tasks.request(TaskKind::DeleteWorktree).unwrap();
let buf = render(&mut app);
assert_present(&buf, "Delete Worktree", "confirm title");
assert_present(&buf, "deleting worktree", "delete loader label");
}
#[test]
fn confirm_modal_renders_delete_failure_after_async_delete_fails() {
let (_dir, mut app) = make_app();
app.worktrees.push(deletable_worktree("feat-257-loader"));
app.list_state.select(Some(app.worktrees.len() - 1));
app.view = View::Confirm;
app.delete_failure = Some("permission denied".into());
let buf = render(&mut app);
assert_present(&buf, "delete failed", "delete failure label");
assert_present(&buf, "permission denied", "delete failure detail");
assert_present(&buf, "Cancel", "cancel button after failure");
}
#[test]
fn report_modal_renders_title_and_step_labels() {
let (_dir, mut app) = make_app();
app.report = Some(BootstrapReport {
steps: vec![
StepResult::ok("copy env file"),
StepResult::skipped("npm install", "no package.json"),
],
});
app.view = View::Report;
let buf = render(&mut app);
assert_present(&buf, "Bootstrap Report", "report title");
assert_present(&buf, "Logs", "report logs section title");
assert_present(&buf, "copy env file", "report ok step label");
assert_present(&buf, "npm install", "report skipped step label");
}
#[test]
fn command_logs_modal_renders_title_and_entry_argv() {
use gwm::command_log::{CommandLogEntry, CommandStatus};
use std::time::Duration;
let (_dir, mut app) = make_app();
app.command_logs.entries = vec![CommandLogEntry {
command: "gh issue view 226 --json title,body".into(),
duration: Duration::from_millis(412),
status: CommandStatus::Exited(Some(0)),
output: "ok".into(),
}];
app.view = View::CommandLogs;
let buf = render(&mut app);
assert_present(&buf, "Command Logs", "command logs title");
assert_present(&buf, "gh issue view 226", "logged command argv");
assert_present(&buf, "copy", "command logs copy hint");
}
#[test]
fn command_logs_modal_renders_empty_placeholder() {
let (_dir, mut app) = make_app();
app.command_logs.entries.clear();
app.view = View::CommandLogs;
let buf = render(&mut app);
assert_present(&buf, "Command Logs", "command logs title");
assert_present(&buf, "No commands", "empty-state placeholder");
}
#[test]
fn command_logs_modal_keeps_title_and_footer_fixed_while_body_scrolls() {
use gwm::command_log::{CommandLogEntry, CommandStatus};
use std::time::Duration;
let (_dir, mut app) = make_app();
app.command_logs.entries = (0..12)
.map(|i| CommandLogEntry {
command: format!("command number {i}"),
duration: Duration::from_millis(10),
status: CommandStatus::Exited(Some(0)),
output: "some output".into(),
})
.collect();
app.view = View::CommandLogs;
app.command_logs.scroll = u16::MAX;
let backend = TestBackend::new(100, 16);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
assert_present(&buf, "Command Logs", "title stays fixed at the top");
assert_present(&buf, "scroll", "footer hint stays fixed at the bottom");
}
#[test]
fn command_logs_modal_separates_entries_with_a_dashed_rule() {
use gwm::command_log::{CommandLogEntry, CommandStatus};
use std::time::Duration;
let (_dir, mut app) = make_app();
app.command_logs.entries = vec![
CommandLogEntry {
command: "first".into(),
duration: Duration::from_millis(1),
status: CommandStatus::Exited(Some(0)),
output: String::new(),
},
CommandLogEntry {
command: "second".into(),
duration: Duration::from_millis(1),
status: CommandStatus::Exited(Some(0)),
output: String::new(),
},
];
app.view = View::CommandLogs;
let buf = render(&mut app);
assert_present(&buf, "----------", "a dashed rule separates the two entries");
}
#[test]
fn settings_panel_all_tab_renders_title_section_and_source_column() {
use gwm::config::{ConfigRow, ConfigSource};
use gwm::tui::SettingsTab;
let (_dir, mut app) = make_app();
app.config_panel.rows = vec![
ConfigRow {
key: "worktree.base".into(),
value: "\"/tmp/repo-wt\"".into(),
source: ConfigSource::Repo,
},
ConfigRow {
key: "worktree.path_pattern".into(),
value: "\"{type}-{issue}-{desc}\"".into(),
source: ConfigSource::Default,
},
];
app.config_panel.tab = SettingsTab::All;
app.view = View::Config;
let buf = render(&mut app);
assert_present(&buf, "Settings", "settings panel title (renamed from Configuration)");
assert_present(&buf, "[worktree]", "grouped section heading");
assert_present(&buf, "worktree.base", "resolved config key");
assert_present(&buf, "repo", "source column marker");
assert_present(&buf, "default", "default source marker");
}
#[test]
fn settings_keys_tab_renders_scopes_bindings_and_capture_input() {
use gwm::config::ConfigSource;
use gwm::tui::keymap::{Action, Keymap};
use gwm::tui::modal_keymap::ModalKeymap;
use gwm::tui::{build_key_rows, KeyTarget, SettingsTab};
let (_dir, mut app) = make_app();
app.config_panel.key_rows = build_key_rows(&Keymap::defaults(), &ModalKeymap::defaults(), |_| ConfigSource::Default);
app.config_panel.tab = SettingsTab::Keys;
app.view = View::Config;
let buf = render(&mut app);
assert_present(&buf, "Keys", "the Keys tab label in the strip");
assert_present(&buf, "[global]", "global scope heading");
assert_present(&buf, "down", "the first global action slug");
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::Quit))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
let buf = render(&mut app);
assert_present(&buf, "[ ", "capture input box rendered for the selected row");
}
#[test]
fn settings_all_tab_horizontal_pan_reveals_the_last_column_past_the_scrollbar() {
use gwm::config::{ConfigRow, ConfigSource};
use gwm::tui::SettingsTab;
let (_dir, mut app) = make_app();
let mut rows = vec![ConfigRow {
key: "tui.long".into(),
value: format!("{}ZEND", "v".repeat(120)),
source: ConfigSource::Repo,
}];
for i in 0..40 {
rows.push(ConfigRow {
key: format!("tui.k{i}"),
value: "x".into(),
source: ConfigSource::Default,
});
}
app.config_panel.rows = rows;
app.config_panel.tab = SettingsTab::All;
app.view = View::Config;
app.config_panel.x_scroll = u16::MAX;
let buf = render(&mut app);
assert_present(
&buf,
"ZEND",
"horizontal pan must reveal the final cell even with the scrollbar column reserved",
);
}
#[test]
fn settings_panel_theme_tab_renders_tabs_layer_and_editable_field() {
let (_dir, mut app) = make_app();
app.view = View::Config;
let buf = render(&mut app);
assert_present(&buf, "Settings", "settings panel title");
assert_present(&buf, "Theme", "Theme tab label");
assert_present(&buf, "Worktree", "Worktree tab label");
assert_present(&buf, "TUI", "TUI tab label");
assert_present(&buf, "project (.gwm.toml)", "edit-layer subtitle");
assert_present(&buf, "theme preset", "editable theme-preset field label");
}
#[test]
fn open_menu_modal_renders_title_and_targets() {
let (_dir, mut app) = make_app();
app.enter_open_menu();
assert_eq!(app.view, View::OpenMenu);
let buf = render(&mut app);
assert_present(&buf, "Open in Browser", "open menu title");
assert_present(&buf, "Issue", "open menu issue target");
assert_present(&buf, "Pull Request", "open menu pr target");
}
#[test]
fn link_prompt_choose_target_renders_title_and_targets() {
let (_dir, mut app) = make_app();
app.enter_link_prompt();
assert_eq!(app.view, View::LinkPrompt);
let buf = render(&mut app);
assert_present(&buf, "Link", "link prompt title");
assert_present(&buf, "Issue", "link prompt issue target");
assert_present(&buf, "Pull Request", "link prompt pr target");
}
#[test]
fn link_prompt_input_number_renders_prompt() {
let (_dir, mut app) = make_app();
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Issue);
let buf = render(&mut app);
assert_present(&buf, "issue", "link prompt number title");
assert_present(&buf, "#", "link prompt number field");
}
#[test]
fn command_palette_modal_renders_title_and_entries() {
let (_dir, mut app) = make_app();
app.open_command_palette();
assert_eq!(app.view, View::CommandPalette);
let buf = render(&mut app);
assert_present(&buf, "Command Palette", "palette title");
assert_present(&buf, "create", "palette lists the 'create' command entry");
assert!(
!buffer_contains(&buf, "no matching command"),
"palette with empty query must list commands, not the empty notice — buffer rows:\n{}",
row_strings(&buf).join("\n")
);
}
#[test]
fn command_palette_renders_the_input_above_the_matches() {
let (_dir, mut app) = make_app();
app.open_command_palette();
for c in "cre".chars() {
app.palette.push_char(c);
}
let buf = render(&mut app);
let rows = row_strings(&buf);
let input_row = rows
.iter()
.position(|r| r.contains("cre"))
.expect("the typed query must render in the input field");
let match_row = rows
.iter()
.position(|r| r.contains("create"))
.expect("a matching command row must render");
assert!(
input_row < match_row,
"the palette input must render above the matches list (input-first), \
input_row={input_row} match_row={match_row} — buffer rows:\n{}",
rows.join("\n")
);
}
#[test]
fn exec_picker_modal_renders_title_profiles_and_hints() {
let (dir, _) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
"[exec.profiles.build]\ncommand = [\"cargo\", \"build\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
app.sidebar.open = false;
app.enter_exec_picker();
assert_eq!(app.view, View::ExecPicker);
let buf = render(&mut app);
assert_present(&buf, "Run an exec profile", "exec picker title (capitalised)");
assert_present(&buf, "build", "exec profile name");
assert_present(&buf, "run", "exec run hint");
}
#[test]
fn clean_modal_renders_title_report_and_hints() {
let (dir, _) = init_repo();
std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
std::fs::create_dir(dir.path().join("target")).unwrap();
std::fs::write(dir.path().join("target").join("blob"), vec![0u8; 4096]).unwrap();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
app.sidebar.open = false;
app.enter_clean_overlay();
assert_eq!(app.view, View::CleanReport);
let buf = render(&mut app);
assert_present(&buf, "Reclaim build artifacts", "clean overlay title (capitalised)");
assert_present(&buf, "target", "clean artifact name");
assert_present(&buf, "total", "clean total line");
assert_present(&buf, "KiB", "size unit not clipped on the right edge");
}