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")
);
}
fn assert_absent(buf: &Buffer, needle: &str, what: &str) {
assert!(
!buffer_contains(buf, needle),
"{what}: expected {needle:?} NOT to be rendered — 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");
}
fn render_at(app: &mut App, w: u16, h: u16) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, app)).unwrap();
terminal.backend().buffer().clone()
}
#[test]
fn settings_tui_tab_keeps_the_selected_field_visible_on_a_short_terminal() {
use gwm::tui::SettingsTab;
let (_dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Tui;
let fields = SettingsTab::Tui.fields();
for (idx, field) in fields.iter().enumerate() {
app.config_panel.selected = idx;
let buf = render_at(&mut app, 100, 24);
let rows = row_strings(&buf);
let label = field.label();
assert!(
rows.iter().any(|r| r.contains(label)),
"selected field {field:?} ({label:?}) is off screen on a 24-line terminal — \
the user can edit a row they cannot see.\nRendered:\n{}",
rows.join("\n")
);
}
}
#[test]
fn the_create_hint_row_describes_the_mode_that_is_on_screen() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_dir, mut app) = make_app();
app.enter_create();
let buf = render(&mut app);
assert_present(&buf, "free-form", "structured mode must advertise the way across");
assert_present(&buf, "field", "structured mode still rotates fields");
app.handle_create_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
let buf = render(&mut app);
assert_present(&buf, "structured", "free-form must advertise the way back");
let hint_row = row_strings(&buf)
.into_iter()
.find(|r| r.contains("submit") && r.contains('│'))
.expect("the create overlay renders a hint row inside its box");
for absent in ["field", "type"] {
assert!(
!hint_row.contains(absent),
"`{}` names a verb that does nothing in free-form mode — hint row: {}",
absent,
hint_row.trim()
);
}
}
#[test]
fn create_modal_in_freeform_mode_shows_only_the_name_field() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.toggle_mode();
let buf = render(&mut app);
assert_present(&buf, "free-form", "the title states the active mode");
assert_present(&buf, "Name", "the free-form name field");
assert_present(&buf, "Branch", "branch preview label");
assert_present(&buf, "Dir", "dir preview label");
for absent in ["Issue", "Type"] {
assert!(
!buffer_contains(&buf, absent),
"`{}` has no meaning in free-form mode and must not be rendered — buffer rows:\n{}",
absent,
row_strings(&buf).join("\n")
);
}
}
#[test]
fn the_create_preview_expands_this_repo_s_own_patterns() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "wt/{type}-{issue}-{desc}".into();
app.config.worktree.path_pattern = "{issue}_{desc}".into();
app.enter_create();
app.create_form.issue = "42".into();
app.create_form.desc = "cache".into();
let type_str = app.branch_types[app.create_form.type_index].name.clone();
let buf = render(&mut app);
assert_present(
&buf,
&format!("wt/{}-42-cache", type_str),
"the branch preview must come from branch_pattern",
);
assert_present(&buf, "42_cache", "the dir preview must come from path_pattern");
}
#[test]
fn the_statusbar_follows_the_rename_modal_s_mode() {
use gwm::tui::state::create_form::Mode;
let (_dir, mut app) = make_app();
let mut wt = deletable_worktree("spike-redis");
wt.branch = Some("spike-redis".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.create_form.mode, Mode::Freeform);
let buf = render(&mut app);
assert_absent(&buf, "↑/↓ type", "free-form has no type selector to advertise");
assert_present(
&buf,
"structured",
"the toggle is the one verb the visible inputs cannot suggest",
);
app.create_form.toggle_mode();
let buf = render(&mut app);
assert_present(&buf, "↑/↓ type", "structured mode does have a type selector");
assert_present(&buf, "free-form", "and advertises the way across");
}
#[test]
fn the_rename_preview_shows_a_free_form_name_verbatim() {
use gwm::tui::state::create_form::Mode;
let (_dir, mut app) = make_app();
let mut wt = deletable_worktree("spike-redis");
wt.branch = Some("spike-redis".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit, "the form must open: {}", app.status);
assert_eq!(app.create_form.mode, Mode::Freeform);
app.create_form.name = "spike/valkey".into();
let buf = render(&mut app);
assert_present(&buf, "spike/valkey", "the branch preview is the name, verbatim");
assert_present(&buf, "spike-valkey", "the dir preview is the name, flattened");
assert!(
!buffer_contains(&buf, "#0-"),
"no pattern is expanded in free-form mode — buffer rows:\n{}",
row_strings(&buf).join("\n")
);
}
#[test]
fn the_rename_pr_warning_fires_on_a_free_form_rename_too() {
use gwm::github::PrState;
let (_dir, mut app) = make_app();
let mut wt = deletable_worktree("spike-redis");
wt.branch = Some("spike-redis".into());
wt.link.pr = Some(77);
wt.pr_state = Some(PrState::Open);
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
let buf = render(&mut app);
assert_absent(&buf, "closes PR #77", "an unchanged name renames nothing");
app.create_form.name = "spike-valkey".into();
let buf = render(&mut app);
assert_present(&buf, "closes PR #77", "a free-form rename closes the PR just the same");
}
#[test]
fn the_rename_preview_expands_this_repo_s_own_patterns() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "feat/#{issue}-{desc}".into();
let mut wt = deletable_worktree("login");
wt.branch = Some("feat/#42-login".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit, "the form must open: {}", app.status);
app.create_form.type_index = app
.branch_types
.iter()
.position(|t| t.name == "docs")
.expect("docs is configured");
let buf = render(&mut app);
assert_present(
&buf,
"feat/#42-login",
"the branch preview must be what branch_pattern would write",
);
assert!(
!buffer_contains(&buf, "docs/#42-login"),
"the preview must not offer a branch the pattern cannot write — buffer rows:\n{}",
row_strings(&buf).join("\n")
);
}
#[test]
fn the_rename_refusal_fits_in_the_modal() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "feat/#{issue}-{desc}".into();
app.config.worktree.path_pattern = "fix-{issue}-{desc}".into();
let mut wt = deletable_worktree("login");
wt.branch = Some("feat/#42-login".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit, "the form must open: {}", app.status);
app.create_form.type_index = app
.branch_types
.iter()
.position(|t| t.name == "docs")
.expect("docs is configured");
app.submit_edit_worktree().expect("the refusal is a form failure");
let failure = app.edit_failure.clone().expect("refused");
let buf = render(&mut app);
assert_present(&buf, &failure, "the whole refusal, not the part that fits");
}
#[test]
fn the_rename_modal_warns_before_a_branch_change_closes_an_open_pr() {
let (_dir, mut app) = make_app();
let mut wt = deletable_worktree("login");
wt.branch = Some("feat/#42-login".into());
wt.link.pr = Some(476);
wt.pr_state = Some(gwm::github::PrState::Open);
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit, "the form must open: {}", app.status);
let buf = render(&mut app);
assert_absent(&buf, "closes PR #476", "an unchanged branch renames nothing");
app.create_form.desc = "login-v2".into();
let buf = render(&mut app);
assert_present(
&buf,
"closes PR #476",
"a branch change deletes the remote branch, which closes the PR",
);
}
#[test]
fn the_rename_modal_stays_quiet_when_only_the_directory_moves() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "feat/#42-login".into();
app.config.worktree.path_pattern = "{type}-{issue}-{desc}".into();
let mut wt = deletable_worktree("login");
wt.branch = Some("feat/#42-login".into());
wt.link.pr = Some(476);
wt.pr_state = Some(gwm::github::PrState::Open);
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit, "the form must open: {}", app.status);
app.create_form.desc = "login-v2".into();
let buf = render(&mut app);
assert_absent(&buf, "closes PR", "a path-only edit never touches a ref");
}
#[test]
fn the_rename_modal_stays_quiet_about_a_pr_that_is_already_closed() {
let (_dir, mut app) = make_app();
let mut wt = deletable_worktree("login");
wt.branch = Some("feat/#42-login".into());
wt.link.pr = Some(476);
wt.pr_state = Some(gwm::github::PrState::Merged);
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
app.create_form.desc = "login-v2".into();
let buf = render(&mut app);
assert_absent(&buf, "closes PR", "a merged PR cannot be closed by a rename");
}
#[test]
fn the_create_modal_omits_a_field_the_patterns_never_write() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "{type}/{desc}".into();
app.config.worktree.path_pattern = "{type}-{desc}".into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
app.enter_create();
let buf = render(&mut app);
assert_present(&buf, "Type", "the pattern writes a type");
assert_present(&buf, "Desc", "and a description");
assert!(
!buffer_contains(&buf, "Issue"),
"no pattern carries {{issue}}, so no Issue field — buffer rows:\n{}",
row_strings(&buf).join("\n")
);
}
#[test]
fn the_create_modal_keeps_a_field_only_the_base_path_writes() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "{type}/{desc}".into();
app.config.worktree.path_pattern = "{type}-{desc}".into();
app.config.worktree.base = "/tmp/wt/{issue}".into();
app.apply_create_form_fields();
app.enter_create();
let buf = render(&mut app);
assert_present(&buf, "Issue", "base writes the issue number into the path");
}
#[test]
fn the_rename_modal_omits_the_same_field_the_create_modal_does() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "{type}/{desc}".into();
app.config.worktree.path_pattern = "{type}-{desc}".into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
let mut wt = deletable_worktree("foo");
wt.branch = Some("feat/my-desc".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, gwm::tui::View::Edit, "the rename form must open");
let buf = render(&mut app);
assert_present(&buf, "Rename", "the rename modal is up");
assert_present(&buf, "Desc", "the pattern writes a description");
assert!(
!buffer_contains(&buf, "Issue"),
"no pattern carries {{issue}}, so no Issue field — buffer rows:\n{}",
row_strings(&buf).join("\n")
);
}
#[test]
fn the_hint_row_drops_the_type_selector_when_no_pattern_carries_one() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "#{issue}-{desc}".into();
app.config.worktree.path_pattern = "{issue}-{desc}".into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
app.enter_create();
let buf = render(&mut app);
assert_absent(&buf, "↑/↓", "no type selector is rendered, so its keys do nothing");
assert_present(&buf, "field", "two fields remain, so Tab still moves");
}
#[test]
fn the_hint_row_drops_the_field_verb_when_the_pattern_presents_one_field() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "wt/{desc}".into();
app.config.worktree.path_pattern = "{desc}".into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
app.enter_create();
assert_eq!(app.create_form.fields().len(), 1);
let buf = render(&mut app);
assert_absent(&buf, "↑/↓", "no type selector either");
assert_absent(&buf, "field", "one field, so Tab is a no-op");
assert_present(&buf, "submit", "the verbs that still work stay");
}
#[test]
fn the_hint_row_is_unchanged_on_the_canonical_pattern() {
let (_dir, mut app) = make_app();
app.enter_create();
let buf = render(&mut app);
assert_present(&buf, "↑/↓", "the default pattern has a type selector");
assert_present(&buf, "field", "and three fields to move between");
}
#[test]
fn no_create_surface_names_a_segment_the_patterns_omit() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::state::create_form::Mode;
for (branch, path, omitted) in [
("{type}/{desc}", "{type}-{desc}", "issue"),
("#{issue}-{desc}", "{issue}-{desc}", "type"),
("{type}/#{issue}", "{type}-{issue}", "desc"),
] {
for freeform in [false, true] {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = branch.into();
app.config.worktree.path_pattern = path.into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
app.enter_create();
if freeform {
app.handle_create_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
assert_eq!(app.create_form.mode, Mode::Freeform);
}
let buf = render(&mut app);
assert!(
!buffer_contains(&buf, omitted),
"`{}` / `{}` (free-form: {}) writes no {{{}}}, but the surface names it — buffer rows:\n{}",
branch,
path,
freeform,
omitted,
row_strings(&buf).join("\n")
);
}
}
}
#[test]
fn an_all_literal_pattern_set_names_no_field_at_all() {
let (_dir, mut app) = make_app();
app.config.worktree.branch_pattern = "wip".into();
app.config.worktree.path_pattern = "wip".into();
app.config.worktree.base = "/tmp/wt".into();
app.apply_create_form_fields();
app.enter_create();
assert!(app.create_form.fields().is_empty());
assert_eq!(app.status, "enter: submit — esc: cancel");
let buf = render(&mut app);
for absent in ["Type", "Issue", "Desc"] {
assert!(
!buffer_contains(&buf, absent),
"`{}` is not presented — buffer rows:\n{}",
absent,
row_strings(&buf).join("\n")
);
}
}