mod common;
use common::init_repo;
use gwm::naming::BRANCH_TYPES;
use gwm::tui::theme::Theme;
use gwm::tui::{
branch_name_color, filled_cells_for_progress, freshness_color, panel_border_color, pr_badge_color, App,
ConfirmKeyAction, CountdownTickOutcome, Field, View,
};
use gwm::worktree::{BranchStatus, WorktreeInfo};
use ratatui::style::Color;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn worktree_fixture(name: &str) -> WorktreeInfo {
WorktreeInfo {
name: name.into(),
id: name.into(),
path: PathBuf::from(format!("/tmp/gwm-test/{}", name)),
branch: Some(format!("feat/#0-{}", name)),
head: None,
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 make_app() -> (tempfile::TempDir, App) {
let (dir, _) = init_repo();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
(dir, app)
}
#[test]
fn focus_status_opens_and_focuses_the_sidebar() {
let (_dir, mut app) = make_app();
app.sidebar.open = false;
app.sidebar.focused = false;
app.focus_status();
assert!(app.sidebar.open, "focus_status must open a closed sidebar");
assert!(app.sidebar.focused, "focus_status must focus the sidebar");
}
#[test]
fn focus_worktrees_releases_sidebar_focus() {
let (_dir, mut app) = make_app();
app.sidebar.open = true;
app.sidebar.focused = true;
app.focus_worktrees();
assert!(!app.sidebar.focused, "focus_worktrees must release sidebar focus");
}
#[test]
fn enter_command_logs_opens_the_overlay_syncs_and_resets_scroll() {
use gwm::command_log::{self, CommandLogEntry, CommandStatus};
use std::time::Duration;
let sentinel = "gwm-enter-cmdlog-9a1c";
command_log::record(CommandLogEntry {
command: format!("gh pr list # {sentinel}"),
duration: Duration::from_millis(1),
status: CommandStatus::Exited(Some(0)),
output: String::new(),
});
let (_dir, mut app) = make_app();
app.command_logs.scroll = 9;
app.command_logs.x_scroll = 3;
app.enter_command_logs();
assert_eq!(app.view, View::CommandLogs);
assert_eq!(app.command_logs.scroll, 0, "scroll resets on open");
assert_eq!(app.command_logs.x_scroll, 0, "horizontal scroll resets on open");
assert!(
app.command_logs.entries.iter().any(|e| e.command.contains(sentinel)),
"opening the overlay snapshots the global command log"
);
}
#[test]
fn enter_config_panel_opens_resolves_rows_and_resets_scroll() {
use gwm::config::ConfigSource;
let (_dir, mut app) = make_app();
app.config_panel.scroll = 9;
app.config_panel.x_scroll = 3;
app.enter_config_panel();
assert_eq!(app.view, View::Config);
assert_eq!(app.config_panel.scroll, 0, "scroll resets on open");
assert_eq!(app.config_panel.x_scroll, 0, "horizontal scroll resets on open");
assert!(
!app.config_panel.rows.is_empty(),
"opening resolves the effective config into rows"
);
let base = app
.config_panel
.rows
.iter()
.find(|r| r.key == "worktree.base")
.expect("worktree.base resolved");
assert_eq!(base.source, ConfigSource::Default);
}
#[test]
fn enter_config_panel_builds_the_keys_tab_rows() {
use gwm::tui::keymap::Action;
use gwm::tui::modal_keymap::ModalAction;
let (_dir, mut app) = make_app();
app.enter_config_panel();
let expected = Action::all().count() + ModalAction::all().count();
assert_eq!(
app.config_panel.key_rows.len(),
expected,
"opening the panel enumerates every global + modal binding"
);
}
#[test]
fn capturing_a_global_chord_rebinds_it_live_and_writes_the_file() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{KeyTarget, SettingsTab};
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::Quit))
.expect("quit row present");
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::NONE));
app.commit_key_capture();
let q = KeyStroke::new(KeyCode::Char('Q'), KeyModifiers::NONE);
assert_eq!(app.keymap.lookup(&[q]), ChordResolution::Matched(Action::Quit));
let raw = std::fs::read_to_string(dir.path().join(".gwm.toml")).unwrap();
assert!(raw.contains("quit = [\"Q\"]"), "binding persisted: {raw}");
}
#[test]
fn capturing_a_modal_verb_rebinds_it_live() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::{KeyContext, ModalAction};
use gwm::tui::{KeyTarget, SettingsTab};
let (_dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Modal(ModalAction::ConfirmConfirm))
.expect("confirm row present");
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
app.commit_key_capture();
let o = KeyStroke::new(KeyCode::Char('o'), KeyModifiers::NONE);
assert_eq!(
app.modal_keymap.resolve(KeyContext::Confirm, &o),
Some(ModalAction::ConfirmConfirm),
"the modal verb fires on its new key immediately"
);
}
#[test]
fn an_invalid_rebind_is_rejected_and_leaves_the_binding_live() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{KeyTarget, SettingsTab};
let (_dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::Refresh))
.expect("refresh row present");
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
app.commit_key_capture();
assert!(app.status.starts_with("keys:"), "error surfaced: {}", app.status);
let f = KeyStroke::new(KeyCode::Char('f'), KeyModifiers::NONE);
assert_eq!(app.keymap.lookup(&[f]), ChordResolution::Matched(Action::Refresh));
}
#[test]
fn cancelling_a_capture_writes_nothing() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.selected = 0;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
app.config_panel.cancel_capture();
assert!(app.config_panel.capture.is_none(), "capture cleared on cancel");
assert!(
!dir.path().join(".gwm.toml").exists(),
"a cancelled capture must not write the file"
);
}
#[test]
fn a_cross_layer_conflict_rolls_back_and_does_not_brick_the_config() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::config::Config;
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let home = tempfile::tempdir().unwrap();
let global = home.path().join("gwm").join("config.toml");
set_array_at(&global, "tui.keys.top", &["z z".to_string()]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), Some(&global)).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Project; let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::Refresh))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
app.commit_key_capture();
assert!(app.status.starts_with("keys:"), "rejection surfaced: {}", app.status);
assert!(
Config::load_layered(repo.path(), Some(&global)).is_ok(),
"the layered config must still load after a rolled-back rebind"
);
let repo_toml = repo.path().join(".gwm.toml");
if repo_toml.exists() {
let raw = std::fs::read_to_string(&repo_toml).unwrap();
assert!(!raw.contains("refresh"), "the rejected rebind was rolled back: {raw}");
}
let f = KeyStroke::new(KeyCode::Char('f'), KeyModifiers::NONE);
assert_eq!(app.keymap.lookup(&[f]), ChordResolution::Matched(Action::Refresh));
}
#[test]
fn a_shadowed_global_key_rebind_warns() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::Action;
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let home = tempfile::tempdir().unwrap();
let global = home.path().join("gwm").join("config.toml");
set_array_at(&repo.path().join(".gwm.toml"), "tui.keys.quit", &["x".to_string()]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), Some(&global)).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Global;
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();
app.push_key_capture(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
app.commit_key_capture();
assert!(
app.status.contains("shadowed"),
"a shadowed global rebind must warn: {}",
app.status
);
}
#[test]
fn physical_enter_stays_reserved_even_with_a_custom_config_edit_submit() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::{KeyContext, ModalAction};
use gwm::tui::{App, KeyTarget, SettingsTab};
let (repo, _) = init_repo();
set_array_at(
&repo.path().join(".gwm.toml"),
"tui.keys.modal.config.edit.submit",
&["Ctrl+s".to_string()],
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Modal(ModalAction::ConfirmConfirm))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.handle_capture_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.config_panel.capture.is_some(), "physical Enter must stay reserved");
let enter = KeyStroke::new(KeyCode::Enter, KeyModifiers::NONE);
assert_ne!(
app.modal_keymap.resolve(KeyContext::Confirm, &enter),
Some(ModalAction::ConfirmConfirm),
"Enter must not have become the binding"
);
}
#[test]
fn a_failed_write_to_an_already_invalid_shadowed_file_rolls_back() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::Action;
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let home = tempfile::tempdir().unwrap();
let global = home.path().join("gwm").join("config.toml");
std::fs::create_dir_all(global.parent().unwrap()).unwrap();
std::fs::write(&global, "[tui]\nconfirm_countdown_secs = \"abc\"\n").unwrap();
std::fs::write(repo.path().join(".gwm.toml"), "[tui]\nconfirm_countdown_secs = 4\n").unwrap();
let mut app = App::new_at_layered(Some(repo.path()), Some(&global)).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Global; 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();
app.push_key_capture(KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::NONE));
app.commit_key_capture();
assert!(app.status.starts_with("keys:"), "failure surfaced: {}", app.status);
let raw = std::fs::read_to_string(&global).unwrap();
assert!(
!raw.contains("quit"),
"a failed write must be rolled back, not persisted: {raw}"
);
}
#[test]
fn modal_capture_reserves_enter_and_backspace_as_controls() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::{KeyContext, ModalAction};
use gwm::tui::{KeyTarget, SettingsTab};
let (_dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Modal(ModalAction::ConfirmConfirm))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.handle_capture_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.config_panel.capture.is_some(),
"Enter must not capture a modal verb"
);
app.handle_capture_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(
app.config_panel.capture.is_some(),
"Backspace must not capture a modal verb"
);
let enter = KeyStroke::new(KeyCode::Enter, KeyModifiers::NONE);
assert_ne!(
app.modal_keymap.resolve(KeyContext::Confirm, &enter),
Some(ModalAction::ConfirmConfirm),
"Enter must not have become the binding"
);
app.handle_capture_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(app.config_panel.capture.is_none(), "a real key commits the capture");
let o = KeyStroke::new(KeyCode::Char('o'), KeyModifiers::NONE);
assert_eq!(
app.modal_keymap.resolve(KeyContext::Confirm, &o),
Some(ModalAction::ConfirmConfirm)
);
}
#[test]
fn global_capture_commits_on_enter_and_pops_on_backspace() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{KeyTarget, SettingsTab};
let (_dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::Refresh))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.handle_capture_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
app.handle_capture_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
assert!(
app.config_panel.capture.is_some(),
"global chord accumulates, no auto-commit"
);
app.handle_capture_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); app.handle_capture_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.config_panel.capture.is_none(), "Enter commits the global chord");
let x = KeyStroke::new(KeyCode::Char('x'), KeyModifiers::NONE);
assert_eq!(app.keymap.lookup(&[x]), ChordResolution::Matched(Action::Refresh));
}
#[test]
fn an_unbind_shadowed_by_another_layer_warns() {
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::Action;
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let home = tempfile::tempdir().unwrap();
let global = home.path().join("gwm").join("config.toml");
set_array_at(&repo.path().join(".gwm.toml"), "tui.keys.quit", &["x".to_string()]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), Some(&global)).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Global;
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();
app.commit_key_capture();
assert!(
app.status.contains("unbound"),
"status reports the unbind: {}",
app.status
);
assert!(
app.status.contains("shadowed"),
"a shadowed unbind must warn: {}",
app.status
);
}
#[test]
fn a_cross_layer_alias_shadow_is_detected_and_warned() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let home = tempfile::tempdir().unwrap();
let global = home.path().join("gwm").join("config.toml");
set_array_at(&global, "tui.keys.open_menu", &["B".to_string()]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), Some(&global)).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Project;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::BrowseLinks))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
app.commit_key_capture();
assert!(
app.status.contains("shadowed"),
"a cross-layer alias shadow must warn: {}",
app.status
);
let z = KeyStroke::new(KeyCode::Char('z'), KeyModifiers::NONE);
assert_ne!(app.keymap.lookup(&[z]), ChordResolution::Matched(Action::BrowseLinks));
}
#[test]
fn rebinding_an_aliased_action_strips_the_legacy_alias() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::config_cli::set_array_at;
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
use gwm::tui::{App, KeyTarget, SettingsLayer, SettingsTab};
let (repo, _) = init_repo();
let toml = repo.path().join(".gwm.toml");
set_array_at(&toml, "tui.keys.open_menu", &["B".to_string()]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Keys;
app.config_panel.layer = SettingsLayer::Project;
let idx = app
.config_panel
.key_rows
.iter()
.position(|r| r.target == KeyTarget::Global(Action::BrowseLinks))
.unwrap();
app.config_panel.selected = idx;
app.config_panel.begin_capture();
app.push_key_capture(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
app.commit_key_capture();
let raw = std::fs::read_to_string(&toml).unwrap();
assert!(!raw.contains("open_menu"), "the legacy alias was stripped: {raw}");
let z = KeyStroke::new(KeyCode::Char('z'), KeyModifiers::NONE);
assert_eq!(app.keymap.lookup(&[z]), ChordResolution::Matched(Action::BrowseLinks));
}
#[test]
fn hint_context_follows_focus() {
use gwm::tui::HintContext;
let (_dir, mut app) = make_app();
app.focus_worktrees();
assert_eq!(app.hint_context(), HintContext::Worktrees);
app.focus_status();
assert_eq!(app.hint_context(), HintContext::Status);
}
#[test]
fn focused_panel_border_wears_the_theme_focus_colour() {
let theme = Theme::default();
assert_eq!(
panel_border_color(true, &theme),
theme.focus,
"focused panel must wear the theme focus colour"
);
assert_eq!(
panel_border_color(false, &theme),
theme.muted,
"unfocused panel wears the theme muted role (#170)"
);
}
#[test]
fn new_loads_main_worktree() {
let (_dir, app) = make_app();
assert_eq!(app.worktrees.len(), 1);
assert!(app.worktrees[0].is_main);
}
#[test]
fn enter_create_opens_focused_on_the_issue_field() {
let (_dir, mut app) = make_app();
app.enter_create();
assert_eq!(app.view, View::Create);
assert_eq!(app.create_form.field, Field::Issue);
assert_eq!(app.create_form.type_index, 0, "type keeps its default");
assert!(app.create_form.issue.is_empty());
assert!(app.create_form.desc.is_empty());
}
#[test]
fn create_field_navigation_loops() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Type;
app.create_next_field();
assert_eq!(app.create_form.field, Field::Issue);
app.create_next_field();
assert_eq!(app.create_form.field, Field::Desc);
app.create_next_field();
assert_eq!(app.create_form.field, Field::Type);
app.create_prev_field();
assert_eq!(app.create_form.field, Field::Desc);
}
#[test]
fn create_type_navigation_loops() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_prev_type();
assert_eq!(app.create_form.type_index, BRANCH_TYPES.len() - 1);
app.create_next_type();
assert_eq!(app.create_form.type_index, 0);
}
#[test]
fn create_push_only_digits_on_issue() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Issue;
for c in "12a3".chars() {
app.create_push_char(c);
}
assert_eq!(app.create_form.issue, "123");
}
#[test]
fn create_push_accepts_desc_chars() {
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Desc;
for c in "foo-bar".chars() {
app.create_push_char(c);
}
assert_eq!(app.create_form.desc, "foo-bar");
app.create_pop_char();
assert_eq!(app.create_form.desc, "foo-ba");
}
#[test]
fn enter_confirm_delete_refuses_main() {
let (_dir, mut app) = make_app();
app.enter_confirm_delete();
assert_eq!(app.view, View::List, "main worktree should not allow delete view");
}
#[test]
fn toggle_delete_branch_flips() {
let (_dir, mut app) = make_app();
assert!(!app.delete_branch_on_remove);
app.toggle_delete_branch();
assert!(app.delete_branch_on_remove);
}
#[test]
fn next_prev_with_single_entry_stays_put() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(0));
app.next();
assert_eq!(app.list_state.selected(), Some(0));
app.prev();
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn refresh_keeps_selection_in_bounds() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(5));
app.refresh().unwrap();
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn sidebar_open_by_default() {
let (_dir, app) = make_app();
assert!(
app.sidebar.open,
"sidebar should default to open (will be hidden when narrow)"
);
assert!(!app.sidebar.focused, "focus defaults to the worktree list");
}
#[test]
fn toggle_sidebar_flips_open_flag() {
let (_dir, mut app) = make_app();
let before = app.sidebar.open;
app.toggle_sidebar();
assert_eq!(app.sidebar.open, !before);
app.toggle_sidebar();
assert_eq!(app.sidebar.open, before);
}
#[test]
fn toggle_sidebar_when_closed_resets_focus_to_list() {
let (_dir, mut app) = make_app();
app.sidebar.focused = true;
app.sidebar.open = true;
app.toggle_sidebar(); assert!(!app.sidebar.open);
assert!(
!app.sidebar.focused,
"closing the sidebar must drop focus back to the list"
);
}
#[test]
fn toggle_focus_only_works_when_sidebar_open() {
let (_dir, mut app) = make_app();
app.sidebar.open = false;
app.toggle_focus();
assert!(!app.sidebar.focused, "focus cannot move to a hidden sidebar");
app.sidebar.open = true;
app.toggle_focus();
assert!(app.sidebar.focused);
app.toggle_focus();
assert!(!app.sidebar.focused);
}
#[test]
fn first_selects_first_worktree() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(0));
app.first();
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn last_selects_last_worktree() {
let (_dir, mut app) = make_app();
app.last();
let expected = app.worktrees.len().saturating_sub(1);
assert_eq!(app.list_state.selected(), Some(expected));
}
#[test]
fn handle_g_motion_tracks_pending_then_jumps_to_first() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(0));
assert!(!app.pending_g);
app.handle_g();
assert!(app.pending_g, "first 'g' must arm the gg sequence");
app.handle_g();
assert!(!app.pending_g, "second 'g' completes gg and disarms");
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn pending_g_resets_on_other_key() {
let (_dir, mut app) = make_app();
app.handle_g();
assert!(app.pending_g);
app.cancel_pending_motion();
assert!(!app.pending_g, "any non-g keypress must drop the pending motion");
}
#[test]
fn sidebar_scroll_clamps_to_zero() {
let (_dir, mut app) = make_app();
assert_eq!(app.sidebar.scroll, 0);
app.sidebar_scroll_up();
assert_eq!(app.sidebar.scroll, 0, "scrolling up from 0 stays at 0");
app.sidebar.max_scroll = 5;
app.sidebar_scroll_down();
assert_eq!(app.sidebar.scroll, 1);
app.sidebar_scroll_up();
assert_eq!(app.sidebar.scroll, 0);
}
#[test]
fn sidebar_scroll_clamps_at_max() {
let (_dir, mut app) = make_app();
app.sidebar.max_scroll = 3;
app.sidebar_scroll_down();
app.sidebar_scroll_down();
app.sidebar_scroll_down();
assert_eq!(app.sidebar.scroll, 3);
app.sidebar_scroll_down();
assert_eq!(app.sidebar.scroll, 3, "scrolling beyond max must clamp");
}
#[test]
fn focus_routes_navigation_to_sidebar() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(0));
app.sidebar.open = true;
app.sidebar.focused = true;
app.sidebar.max_scroll = 5;
app.next();
assert_eq!(
app.list_state.selected(),
Some(0),
"list must stay put when sidebar has focus"
);
assert!(
app.sidebar.scroll >= 1,
"next() must scroll the sidebar when it has focus"
);
app.prev();
assert_eq!(app.list_state.selected(), Some(0));
assert_eq!(app.sidebar.scroll, 0, "prev() scrolled back up");
}
#[test]
fn next_prev_invalidate_sidebar_cache() {
let (_dir, mut app) = make_app();
app.sidebar.cache = Some((
(
std::path::PathBuf::from("/tmp/x"),
gwm::tui::state::sidebar::SidebarMode::Commits,
),
Default::default(),
));
app.next();
assert!(app.sidebar.cache.is_none(), "next() must invalidate the sidebar cache");
app.sidebar.cache = Some((
(
std::path::PathBuf::from("/tmp/x"),
gwm::tui::state::sidebar::SidebarMode::Commits,
),
Default::default(),
));
app.prev();
assert!(app.sidebar.cache.is_none(), "prev() must invalidate the sidebar cache");
}
#[test]
fn refresh_invalidates_sidebar_cache() {
let (_dir, mut app) = make_app();
app.sidebar.cache = Some((
(
std::path::PathBuf::from("/tmp/x"),
gwm::tui::state::sidebar::SidebarMode::Commits,
),
Default::default(),
));
app.refresh().unwrap();
assert!(app.sidebar.cache.is_none());
}
#[test]
fn on_navigation_resets_scroll_and_invalidates_sidebar_cache() {
let (_dir, mut app) = make_app();
app.sidebar.scroll = 7;
app.sidebar.cache = Some((
(
std::path::PathBuf::from("/tmp/x"),
gwm::tui::state::sidebar::SidebarMode::Commits,
),
Default::default(),
));
app.on_navigation();
assert_eq!(app.sidebar.scroll, 0, "on_navigation must reset scroll to 0");
assert!(
app.sidebar.cache.is_none(),
"on_navigation must drop the cached sidebar sections"
);
}
#[test]
fn filter_state_defaults_to_inactive_and_empty() {
let (_dir, app) = make_app();
assert!(!app.filter.active, "filter must default to inactive");
assert!(app.filter.query().is_empty(), "filter query must default to empty");
}
#[test]
fn enter_filter_activates_capture_and_disarms_gg() {
let (_dir, mut app) = make_app();
app.handle_g(); assert!(app.pending_g);
app.enter_filter();
assert!(app.filter.active);
assert!(
!app.pending_g,
"opening the filter bar must drop any half-typed gg motion"
);
}
#[test]
fn enter_filter_drops_sidebar_focus() {
let (_dir, mut app) = make_app();
app.sidebar.open = true;
app.sidebar.focused = true;
app.enter_filter();
assert!(
!app.sidebar.focused,
"opening the filter bar must hand focus back to the list"
);
}
#[test]
fn enter_filter_preserves_existing_query() {
let (_dir, mut app) = make_app();
app.filter.set_query("auth".into());
app.enter_filter();
assert_eq!(app.filter.query(), "auth");
assert!(app.filter.active);
}
#[test]
fn filter_push_char_appends_to_query() {
let (_dir, mut app) = make_app();
app.enter_filter();
for c in "tui".chars() {
app.filter_push_char(c);
}
assert_eq!(app.filter.query(), "tui");
}
#[test]
fn filter_pop_char_removes_last_char() {
let (_dir, mut app) = make_app();
app.enter_filter();
app.filter.set_query("tuix".into());
app.filter_pop_char();
assert_eq!(app.filter.query(), "tui");
}
#[test]
fn filter_pop_char_on_empty_is_noop() {
let (_dir, mut app) = make_app();
app.enter_filter();
app.filter_pop_char();
assert_eq!(app.filter.query(), "");
assert!(app.filter.active, "popping an empty query must not exit filter mode");
}
#[test]
fn exit_filter_keep_disables_capture_keeps_query() {
let (_dir, mut app) = make_app();
app.enter_filter();
app.filter.set_query("auth".into());
app.exit_filter_keep();
assert!(!app.filter.active);
assert_eq!(app.filter.query(), "auth", "Enter must not wipe the query");
}
#[test]
fn exit_filter_cancel_clears_query() {
let (_dir, mut app) = make_app();
app.enter_filter();
app.filter.set_query("auth".into());
app.exit_filter_cancel();
assert!(!app.filter.active);
assert!(app.filter.query().is_empty(), "Esc must clear the query");
}
#[test]
fn filtered_indices_returns_all_when_query_empty() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("beta"),
worktree_fixture("gamma"),
];
let idx: Vec<usize> = app.filtered_indices().to_vec();
assert_eq!(idx, vec![0, 1, 2], "empty query is the identity over worktrees");
}
#[test]
fn filtered_indices_keeps_only_matching_worktrees() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("feat-1-tui-search"),
worktree_fixture("feat-2-cli-completions"),
worktree_fixture("fix-3-locked-worktree"),
];
app.filter.set_query("tui".into());
let idx: Vec<usize> = app.filtered_indices().to_vec();
let names: Vec<&str> = idx.iter().map(|&i| app.worktrees[i].name.as_str()).collect();
assert_eq!(
names,
vec!["feat-1-tui-search"],
"only the worktree whose name contains 'tui' should match"
);
}
#[test]
fn filtered_indices_supports_subsequence_match() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("a-foo-u-bar-t-baz-h-qux"),
worktree_fixture("chore-1-bump-deps"),
];
app.filter.set_query("auth".into());
assert!(
!app.worktrees[0].name.contains("auth"),
"fixture must not contain 'auth' as a substring or the test stops covering subsequence"
);
let idx: Vec<usize> = app.filtered_indices().to_vec();
assert_eq!(idx.len(), 1);
assert_eq!(app.worktrees[idx[0]].name, "a-foo-u-bar-t-baz-h-qux");
}
#[test]
fn filtered_indices_ranks_substring_above_subsequence() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("a-zzz-u-yyy-t-xxx-h"),
worktree_fixture("auth-service"),
];
app.filter.set_query("auth".into());
let idx: Vec<usize> = app.filtered_indices().to_vec();
assert!(!idx.is_empty(), "at least the substring candidate must match");
assert_eq!(
app.worktrees[idx[0]].name, "auth-service",
"contiguous substring must outrank a spread subsequence"
);
}
#[test]
fn filtered_indices_skips_when_no_match() {
let (_dir, mut app) = make_app();
app.worktrees = vec![worktree_fixture("alpha"), worktree_fixture("beta")];
app.filter.set_query("zzzz".into());
assert!(app.filtered_indices().is_empty());
}
#[test]
fn selected_returns_filtered_worktree() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("authentication"),
worktree_fixture("beta"),
];
app.filter.set_query("auth".into());
app.list_state.select(Some(0));
let sel = app.selected().expect("filtered selection must resolve");
assert_eq!(sel.name, "authentication");
}
#[test]
fn selected_returns_none_when_filter_matches_nothing() {
let (_dir, mut app) = make_app();
app.worktrees = vec![worktree_fixture("alpha"), worktree_fixture("beta")];
app.filter.set_query("zzzz".into());
app.list_state.select(Some(0));
assert!(app.selected().is_none());
}
#[test]
fn next_navigates_within_filtered_subset_and_wraps() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("foo-a"),
worktree_fixture("foo-b"),
];
app.filter.set_query("foo".into());
app.list_state.select(Some(0));
app.next();
assert_eq!(app.list_state.selected(), Some(1));
app.next();
assert_eq!(
app.list_state.selected(),
Some(0),
"wrap-around to start of filtered subset"
);
}
#[test]
fn prev_navigates_within_filtered_subset_and_wraps() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("foo-a"),
worktree_fixture("foo-b"),
];
app.filter.set_query("foo".into());
app.list_state.select(Some(0));
app.prev();
assert_eq!(app.list_state.selected(), Some(1), "wrap-around backwards");
}
#[test]
fn first_and_last_jump_inside_filtered_subset() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("foo-1"),
worktree_fixture("beta"),
worktree_fixture("foo-2"),
worktree_fixture("gamma"),
];
app.filter.set_query("foo".into());
app.list_state.select(Some(1));
app.first();
assert_eq!(app.list_state.selected(), Some(0));
app.last();
assert_eq!(
app.list_state.selected(),
Some(1),
"last must use the filtered length (2 matches → index 1)"
);
}
#[test]
fn filter_push_clamps_selection_when_subset_shrinks() {
let (_dir, mut app) = make_app();
app.worktrees = vec![worktree_fixture("foo-bar"), worktree_fixture("foo-baz-xx")];
app.filter.set_query("foo".into());
app.list_state.select(Some(1));
for c in "-bar".chars() {
app.filter_push_char(c);
}
let filtered: Vec<usize> = app.filtered_indices().to_vec();
assert_eq!(filtered.len(), 1, "only foo-bar should still match foo-bar");
assert_eq!(
app.list_state.selected(),
Some(0),
"selection must clamp to inside the new filtered subset"
);
}
#[test]
fn exit_filter_cancel_restores_full_list_selection() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"),
worktree_fixture("foo"),
worktree_fixture("beta"),
];
app.filter.set_query("foo".into());
app.list_state.select(Some(0));
app.exit_filter_cancel();
assert!(app.filter.query().is_empty());
assert_eq!(app.filtered_indices(), vec![0, 1, 2]);
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn picker_mode_defaults_to_false() {
let (_dir, app) = make_app();
assert!(!app.picker_mode, "default App must not be in picker mode");
assert!(
app.picker_result.is_none(),
"no path is selected until the user confirms"
);
}
#[test]
fn new_picker_at_enables_picker_mode() {
let (dir, _) = init_repo();
let app = App::new_picker_at_layered(Some(dir.path()), None).unwrap();
assert!(app.picker_mode, "new_picker_at must set picker_mode=true");
}
#[test]
fn new_picker_at_opens_filter_bar() {
let (dir, _) = init_repo();
let app = App::new_picker_at_layered(Some(dir.path()), None).unwrap();
assert!(app.filter.active, "picker mode must open with the filter bar active");
}
#[test]
fn picker_confirm_records_selected_path() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.list_state.select(Some(0));
let expected = app.selected().expect("test fixture must have a worktree").path.clone();
app.picker_confirm();
assert_eq!(
app.picker_result,
Some(expected),
"picker_confirm must record the selected worktree's path"
);
}
#[test]
fn picker_confirm_with_no_selection_keeps_result_none() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.worktrees.clear();
app.list_state.select(None);
app.picker_confirm();
assert!(
app.picker_result.is_none(),
"picker_confirm with no selection must leave picker_result unset"
);
}
#[test]
fn picker_confirm_outside_picker_mode_is_inert() {
let (_dir, mut app) = make_app();
app.list_state.select(Some(0));
assert!(!app.picker_mode);
app.picker_confirm();
assert!(
app.picker_result.is_none(),
"picker_confirm outside picker mode must not record a path"
);
}
#[test]
fn picker_should_exit_defaults_false() {
let (_dir, app) = make_app();
assert!(!app.picker_should_exit, "newly-built App must not signal a picker exit");
}
#[test]
fn picker_confirm_with_selection_signals_exit() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.list_state.select(Some(0));
app.picker_confirm();
assert!(
app.picker_should_exit,
"successful picker_confirm must signal the event loop to exit"
);
assert!(app.picker_result.is_some());
}
#[test]
fn picker_confirm_without_selection_does_not_signal_exit() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.worktrees.clear();
app.list_state.select(None);
app.picker_confirm();
assert!(
!app.picker_should_exit,
"picker_confirm with no selection must NOT signal exit"
);
assert!(app.picker_result.is_none());
}
#[test]
fn picker_confirm_without_selection_reports_status() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.worktrees.clear();
app.list_state.select(None);
app.picker_confirm();
assert!(
app.status.to_lowercase().contains("no") || app.status.to_lowercase().contains("nothing"),
"picker_confirm with no selection must update the status bar (got: {:?})",
app.status
);
}
#[test]
fn picker_cancel_signals_exit_without_path() {
let (_dir, mut app) = make_app();
app.picker_mode = true;
app.list_state.select(Some(0));
app.picker_cancel();
assert!(
app.picker_should_exit,
"picker_cancel must signal the event loop to exit"
);
assert!(
app.picker_result.is_none(),
"picker_cancel must NOT record a path (Esc is the no-pick exit)"
);
}
#[test]
fn picker_cancel_outside_picker_mode_is_inert() {
let (_dir, mut app) = make_app();
assert!(!app.picker_mode);
app.picker_cancel();
assert!(
!app.picker_should_exit,
"picker_cancel outside picker mode must not flip the exit flag"
);
}
#[test]
fn countdown_total_zero_when_delete_branch_off() {
let (_dir, app) = make_app();
assert!(!app.delete_branch_on_remove);
assert_eq!(app.confirm_countdown_total(), Duration::ZERO);
assert!(!app.confirm_is_countdown_mode());
}
#[test]
fn countdown_total_matches_config_when_delete_branch_on() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
assert!(app.delete_branch_on_remove);
assert_eq!(app.confirm_countdown_total(), Duration::from_secs(3));
assert!(app.confirm_is_countdown_mode());
}
#[test]
fn countdown_total_zero_when_config_says_zero() {
let (_dir, mut app) = make_app();
app.config.tui.confirm_countdown_secs = 0;
app.toggle_delete_branch();
assert_eq!(app.confirm_countdown_total(), Duration::ZERO);
assert!(
!app.confirm_is_countdown_mode(),
"countdown_secs=0 must fall back to the classic modal even when delete_branch is armed"
);
}
#[test]
fn confirm_press_y_in_classic_mode_fires_immediately() {
let (_dir, mut app) = make_app();
let action = app.confirm_press_y(Instant::now());
assert_eq!(action, ConfirmKeyAction::FireNow);
assert!(!app.confirm.is_armed(), "classic mode must never set the timer");
}
#[test]
fn confirm_press_y_in_countdown_mode_arms_the_timer() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let now = Instant::now();
let action = app.confirm_press_y(now);
assert_eq!(action, ConfirmKeyAction::Armed);
assert!(app.confirm.is_armed());
assert_eq!(app.confirm.progress(now, app.confirm_countdown_total()), 0.0);
}
#[test]
fn confirm_press_y_a_second_time_disarms_the_timer() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
let t1 = t0 + Duration::from_millis(500);
let action = app.confirm_press_y(t1);
assert_eq!(action, ConfirmKeyAction::Disarmed);
assert!(!app.confirm.is_armed());
}
#[test]
fn countdown_status_uses_rebound_confirm_keys() {
use gwm::tui::modal_keymap::{parse_single, ModalAction};
let (_dir, mut app) = make_app();
app
.modal_keymap
.apply_override(ModalAction::ConfirmConfirm, vec![parse_single("c").unwrap()])
.unwrap();
app
.modal_keymap
.apply_override(ModalAction::ConfirmCancel, vec![parse_single("x").unwrap()])
.unwrap();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0); assert!(
app.status.contains("press c again or x to cancel"),
"armed copy must use the rebound confirm/cancel keys: {}",
app.status
);
let action = app.confirm_press_y(t0 + Duration::from_millis(500)); assert_eq!(action, ConfirmKeyAction::Disarmed);
assert!(
app.status.contains("press c to re-arm"),
"disarmed copy must use the rebound confirm key: {}",
app.status
);
}
#[test]
fn countdown_status_omits_an_unbound_cancel_key() {
use gwm::tui::modal_keymap::ModalAction;
let (_dir, mut app) = make_app();
app
.modal_keymap
.apply_override(ModalAction::ConfirmCancel, vec![])
.unwrap();
app.toggle_delete_branch();
app.confirm_press_y(Instant::now()); assert!(
!app.status.contains("Esc") && !app.status.contains("to cancel"),
"armed status must not advertise an unbound cancel key: {}",
app.status
);
assert!(
app.status.contains("press y again"),
"the still-bound confirm key must remain in the copy: {}",
app.status
);
}
#[test]
fn confirm_dismiss_resets_timer_and_returns_to_list() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
app.view = View::Confirm;
app.confirm_press_y(Instant::now());
assert!(app.confirm.is_armed());
app.confirm_dismiss();
assert_eq!(app.view, View::List);
assert!(!app.confirm.is_armed(), "Esc/n must always disarm the countdown");
}
#[test]
fn tick_unarmed_is_noop() {
let (_dir, mut app) = make_app();
let outcome = app.tick_confirm_countdown(Instant::now());
assert_eq!(outcome, CountdownTickOutcome::NotArmed);
}
#[test]
fn tick_before_duration_is_pending() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
let outcome = app.tick_confirm_countdown(t0 + Duration::from_millis(1500));
assert_eq!(outcome, CountdownTickOutcome::Pending);
assert!(app.confirm.is_armed(), "pending tick must not clear the timer");
}
#[test]
fn tick_at_duration_signals_ready_to_fire() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
let outcome = app.tick_confirm_countdown(t0 + Duration::from_secs(3));
assert_eq!(outcome, CountdownTickOutcome::ReadyToFire);
}
#[test]
fn tick_past_duration_signals_ready_to_fire() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
let outcome = app.tick_confirm_countdown(t0 + Duration::from_millis(3500));
assert_eq!(outcome, CountdownTickOutcome::ReadyToFire);
}
#[test]
fn countdown_progress_grows_with_elapsed() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
assert!((app.confirm_countdown_progress(t0) - 0.0).abs() < 1e-9);
let mid = app.confirm_countdown_progress(t0 + Duration::from_millis(1500));
assert!((0.49..=0.51).contains(&mid), "got progress = {mid}");
let done = app.confirm_countdown_progress(t0 + Duration::from_secs(10));
assert!((done - 1.0).abs() < 1e-9, "progress clamps to 1.0; got {done}");
}
#[test]
fn countdown_remaining_secs_counts_down_to_zero() {
let (_dir, mut app) = make_app();
app.toggle_delete_branch();
let t0 = Instant::now();
app.confirm_press_y(t0);
assert_eq!(app.confirm_countdown_remaining_secs(t0), 3);
assert_eq!(
app.confirm_countdown_remaining_secs(t0 + Duration::from_millis(2500)),
1
);
assert_eq!(app.confirm_countdown_remaining_secs(t0 + Duration::from_secs(3)), 0);
}
#[test]
fn filled_cells_zero_at_progress_zero() {
assert_eq!(filled_cells_for_progress(0.0, 10), 0);
}
#[test]
fn filled_cells_full_only_at_progress_one() {
assert_eq!(filled_cells_for_progress(1.0, 10), 10);
}
#[test]
fn filled_cells_below_one_keeps_last_cell_empty() {
assert_eq!(filled_cells_for_progress(0.95, 10), 9);
assert!(filled_cells_for_progress(0.99, 10) < 10);
assert!(filled_cells_for_progress(0.999_999, 10) < 10);
}
#[test]
fn filled_cells_clamps_above_one() {
assert_eq!(filled_cells_for_progress(1.5, 10), 10);
}
#[test]
fn filled_cells_floors_partial_progress() {
assert_eq!(filled_cells_for_progress(0.55, 10), 5);
assert_eq!(filled_cells_for_progress(0.5, 10), 5);
}
use gwm::github::{CiState, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
use gwm::tui::{GitHubFetchState, LinkPromptStage, LinkTarget};
fn make_app_on_branch(name: &str) -> (tempfile::TempDir, git2::Repository, App) {
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch(name, &head, false).unwrap();
}
repo.set_head(&format!("refs/heads/{}", name)).unwrap();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
(dir, repo, app)
}
#[test]
fn current_link_reflects_branch_name_auto_detect() {
let (_dir, _repo, app) = make_app_on_branch("feat/#42-tui-search");
let link = app.current_link();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_source, LinkSource::BranchName);
assert_eq!(link.pr, None);
}
#[test]
fn enter_open_menu_transitions_view() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.enter_open_menu();
assert_eq!(app.view, View::OpenMenu);
}
#[test]
fn open_menu_selection_toggles_like_link_prompt() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.enter_open_menu();
assert_eq!(app.open_menu_selected, LinkTarget::Issue);
app.open_menu_toggle_selection();
assert_eq!(app.open_menu_selected, LinkTarget::Pr);
app.open_menu_toggle_selection();
assert_eq!(app.open_menu_selected, LinkTarget::Issue);
}
#[test]
fn open_menu_choose_issue_returns_url_when_linked_and_slug_available() {
let (_dir, repo, mut app) = make_app_on_branch("feat/#42-tui-search");
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
app.enter_open_menu();
let url = app.open_menu_pick(LinkTarget::Issue).unwrap();
assert_eq!(url, "https://github.com/kbrdn1/gwm-cli/issues/42");
assert_eq!(app.view, View::List);
}
#[test]
fn open_menu_pick_returns_none_when_no_link() {
let (_dir, repo, mut app) = make_app_on_branch("random-branch");
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
app.enter_open_menu();
let url = app.open_menu_pick(LinkTarget::Pr);
assert!(url.is_none());
assert!(
app.status.to_lowercase().contains("no pr"),
"status should mention missing PR link: {}",
app.status
);
assert!(
app.status.contains("press i to link"),
"status must use LinkPrompt's real chord, not the stale `L`: {}",
app.status
);
}
#[test]
fn link_open_modal_lines_include_available_links_without_refresh_button() {
use gwm::tui::{link_open_modal_lines, LinkTarget};
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("random-branch", &head, false).unwrap();
}
repo.set_head("refs/heads/random-branch").unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("branch.random-branch.gwm-issue", "42").unwrap();
cfg.set_str("branch.random-branch.gwm-pr", "7").unwrap();
}
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
let text = link_open_modal_lines(&app, "Open in Browser", Some(LinkTarget::Issue))
.into_iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Issue #42"), "Issue summary missing: {text:?}");
assert!(text.contains("PR"), "PR summary missing: {text:?}");
assert!(text.contains("#7"), "PR number missing: {text:?}");
assert!(
!text.contains("Refresh"),
"refresh should be advertised in the hint row, not as a third action button: {text:?}"
);
}
#[test]
fn enter_link_prompt_starts_at_choose_target() {
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert_eq!(app.view, View::LinkPrompt);
assert_eq!(app.link_prompt_stage(), LinkPromptStage::ChooseTarget);
assert!(app.link_prompt_number_input().is_empty());
}
#[test]
fn link_prompt_status_copy_stays_footer_sized() {
const MAX_STATUS_CHARS: usize = 4;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert!(
app.status.chars().count() <= MAX_STATUS_CHARS,
"choose-target status is too long for the footer: {:?}",
app.status
);
app.link_prompt_choose(LinkTarget::Issue);
assert!(
app.status.chars().count() <= MAX_STATUS_CHARS,
"issue-input status is too long for the footer: {:?}",
app.status
);
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Pr);
assert!(
app.status.chars().count() <= MAX_STATUS_CHARS,
"pr-input status is too long for the footer: {:?}",
app.status
);
}
#[test]
fn link_prompt_choose_issue_advances_to_input() {
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Issue);
assert_eq!(app.link_prompt_stage(), LinkPromptStage::InputNumber);
}
#[test]
fn link_prompt_only_accepts_digits() {
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Issue);
for c in "12a3".chars() {
app.link_prompt_push_char(c);
}
assert_eq!(app.link_prompt_number_input(), "123");
}
#[test]
fn link_prompt_submit_writes_branch_config() {
let (_dir, repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Issue);
for c in "42".chars() {
app.link_prompt_push_char(c);
}
app.link_prompt_submit().unwrap();
assert_eq!(app.view, View::List);
let cfg = repo.config().unwrap();
let v = cfg.get_string("branch.random-branch.gwm-issue").unwrap();
assert_eq!(v, "42");
}
#[test]
fn link_prompt_cancel_returns_to_list() {
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.link_prompt_cancel();
assert_eq!(app.view, View::List);
}
#[test]
fn enter_link_prompt_opens_with_issue_highlighted() {
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert_eq!(app.link_prompt_selected(), LinkTarget::Issue);
}
#[test]
fn link_prompt_key_jk_moves_the_highlight_without_committing() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert!(matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
LinkPromptKey::Handled
));
assert_eq!(app.link_prompt_selected(), LinkTarget::Pr, "j moves the highlight down");
assert_eq!(
app.link_prompt_stage(),
LinkPromptStage::ChooseTarget,
"moving commits nothing"
);
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.link_prompt_selected(), LinkTarget::Issue, "k moves it back");
}
#[test]
fn link_prompt_key_enter_links_the_highlighted_target() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); assert!(matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
LinkPromptKey::Handled
));
assert_eq!(
app.link_prompt_stage(),
LinkPromptStage::InputNumber,
"Enter commits + advances"
);
assert_eq!(
app.link_prompt_target(),
Some(LinkTarget::Pr),
"it links the highlighted row"
);
}
#[test]
fn link_prompt_key_i_and_p_remain_direct_picks() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert_eq!(app.link_prompt_stage(), LinkPromptStage::InputNumber);
assert_eq!(app.link_prompt_target(), Some(LinkTarget::Pr), "p picks PR directly");
app.enter_link_prompt(); app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(
app.link_prompt_target(),
Some(LinkTarget::Issue),
"i picks Issue directly"
);
}
#[test]
fn link_prompt_key_digits_then_enter_requests_submit() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); for c in "4a2".chars() {
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert_eq!(
app.link_prompt_number_input(),
"42",
"non-digits dropped during InputNumber"
);
assert!(matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
LinkPromptKey::Submit
));
}
#[test]
fn link_prompt_key_esc_requests_cancel() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert!(matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
LinkPromptKey::Cancel
));
}
#[test]
fn link_prompt_key_fetch_requests_refresh() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert!(matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE)),
LinkPromptKey::Refresh
));
}
#[test]
fn github_fetch_state_default_is_idle() {
let (_dir, _repo, app) = make_app_on_branch("feat/#42-tui-search");
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
assert!(matches!(app.pr_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn apply_fetch_results_loads_issue_and_pr_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let issue = IssueStatus {
number: 42,
title: "TUI search".into(),
state: IssueState::Open,
url: "https://example.test".into(),
labels: vec!["feature".into()],
updated_at: "2026-05-19T00:00:00Z".into(),
};
let pr = PrStatus {
number: 61,
title: "feat(tui): search".into(),
state: PrState::Draft,
url: "https://example.test/pr".into(),
updated_at: "2026-05-19T00:00:00Z".into(),
checks_passed: 2,
checks_total: 3,
ci: CiState::Running,
};
app.apply_issue_fetch_result(Ok(issue.clone()));
app.apply_pr_fetch_result(Ok(pr.clone()));
match app.issue_fetch_state() {
GitHubFetchState::Loaded(_) => {}
other => panic!("expected Loaded for linked issue 42, got {:?}", other),
}
match app.github.pr_fetch_state(61) {
GitHubFetchState::Loaded(_) => {}
other => panic!("expected Loaded for stamped pr 61, got {:?}", other),
}
}
#[test]
fn loaded_issue_status_persists_title_for_no_fetch_startup() {
let (_dir, repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.apply_issue_fetch_result(Ok(IssueStatus {
number: 42,
title: "Persisted issue title".into(),
state: IssueState::Open,
url: "https://example.test/issues/42".into(),
labels: vec![],
updated_at: String::new(),
}));
let link = gwm::github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.issue, Some(42));
assert_eq!(link.issue_title.as_deref(), Some("Persisted issue title"));
}
#[test]
fn loaded_explicit_pr_status_persists_title_for_no_fetch_startup() {
let (_dir, repo, mut app) = make_app_on_branch("feat/#42-tui-search");
gwm::github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
app.refresh_link();
app.apply_pr_fetch_result(Ok(PrStatus {
number: 61,
title: "Persisted explicit PR title".into(),
state: PrState::Open,
url: "https://example.test/pull/61".into(),
updated_at: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
}));
let link = gwm::github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(61));
assert_eq!(link.pr_source, LinkSource::Explicit);
assert_eq!(link.pr_title.as_deref(), Some("Persisted explicit PR title"));
}
#[test]
fn loaded_detected_pr_status_persists_detected_title_for_no_fetch_startup() {
let (_dir, repo, mut app) = make_app_on_branch("feat/#42-tui-search");
gwm::github::persist_detected_pr(&repo, "feat/#42-tui-search", 77).unwrap();
app.refresh_link();
app.apply_pr_fetch_result(Ok(PrStatus {
number: 77,
title: "Persisted detected PR title".into(),
state: PrState::Merged,
url: "https://example.test/pull/77".into(),
updated_at: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
}));
let link = gwm::github::read_link(&repo, "feat/#42-tui-search").unwrap();
assert_eq!(link.pr, Some(77));
assert_eq!(link.pr_source, LinkSource::Detected);
assert_eq!(link.pr_title.as_deref(), Some("Persisted detected PR title"));
}
#[test]
fn github_status_lines_show_persisted_titles_before_fetch() {
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
let mut cfg = repo.config().unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-title", "Startup issue title")
.unwrap();
cfg.set_str("branch.feat/#42-tui-search.gwm-pr-detected", "77").unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-detected-title", "Startup PR title")
.unwrap();
}
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
let text = gwm::tui::github_status_lines(&app, 120)
.into_iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Startup issue title"), "issue title missing: {text}");
assert!(text.contains("Startup PR title"), "PR title missing: {text}");
}
#[test]
fn github_status_lines_show_persisted_state_before_fetch() {
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
}
gwm::github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-title", "Closed issue")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-state", "closed")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-title", "Closed PR")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-state", "closed")
.unwrap();
}
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
let lines = gwm::tui::github_status_lines(&app, 120);
let text = lines
.iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains(" closed "), "persisted issue state missing: {text}");
assert!(text.contains("Closed issue"), "persisted issue title missing: {text}");
assert!(text.contains("Closed PR"), "persisted PR title missing: {text}");
let theme = Theme::default();
assert_eq!(
lines[0].spans[0].style.fg,
Some(gwm::tui::issue_badge_color(IssueState::Closed, &theme)),
"persisted issue icon should use the persisted state colour"
);
assert_eq!(
lines[1].spans[0].style.fg,
Some(gwm::tui::pr_badge_color(PrState::Closed, &theme)),
"persisted PR icon should use the persisted state colour"
);
}
#[test]
fn github_status_lines_keep_persisted_state_visible_while_loading() {
use gwm::tui::FetchKey;
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
}
gwm::github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-title", "Closed issue")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-state", "closed")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-title", "Merged PR")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-state", "merged")
.unwrap();
}
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
app.github.mark_loading(FetchKey::Issue(42));
app.github.mark_loading(FetchKey::Pr(61));
let lines = gwm::tui::github_status_lines(&app, 120);
let text = lines
.iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains(" closed "),
"loading issue line should keep cached state: {text}"
);
assert!(
text.contains(" merged "),
"loading PR line should keep cached state: {text}"
);
assert!(
text.contains("loading"),
"loading line should still disclose the refresh: {text}"
);
let theme = Theme::default();
assert_eq!(
lines[0].spans[0].style.fg,
Some(gwm::tui::issue_badge_color(IssueState::Closed, &theme)),
"loading issue icon should keep the persisted state colour"
);
assert_eq!(
lines[1].spans[0].style.fg,
Some(gwm::tui::pr_badge_color(PrState::Merged, &theme)),
"loading PR icon should keep the persisted state colour"
);
}
fn sample_issue(n: u64) -> gwm::github::IssueStatus {
sample_issue_titled(n, "x")
}
fn sample_issue_titled(n: u64, title: &str) -> gwm::github::IssueStatus {
gwm::github::IssueStatus {
number: n,
title: title.into(),
state: gwm::github::IssueState::Open,
url: String::new(),
labels: vec![],
updated_at: String::new(),
}
}
fn request_github_issue(app: &mut gwm::tui::App, n: u64) -> u64 {
use gwm::tui::{FetchKey, TaskKind};
let generation = app
.tasks
.request(TaskKind::GithubIssue(n))
.expect("a cold GitHub issue slot must hand out a generation");
app.github.mark_loading(FetchKey::Issue(n));
generation
}
fn invalidate_github_for_test(app: &mut gwm::tui::App) {
use gwm::tui::TaskKind;
app.tasks.invalidate_matching(TaskKind::is_github);
app.github.invalidate();
}
#[test]
fn stale_github_fetch_result_loses_to_a_newer_generation() {
use gwm::tui::TaskMsg;
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let gen_a = request_github_issue(&mut app, 42);
invalidate_github_for_test(&mut app);
let gen_b = request_github_issue(&mut app, 42);
assert_ne!(gen_a, gen_b, "the second fetch must own a distinct generation");
let tx = app.task_result_sender();
tx.send(TaskMsg::GithubIssue(gen_b, 42, Ok(sample_issue_titled(42, "FRESH"))))
.unwrap();
tx.send(TaskMsg::GithubIssue(gen_a, 42, Ok(sample_issue_titled(42, "STALE"))))
.unwrap();
app.drain_task_results();
match app.issue_fetch_state() {
GitHubFetchState::Loaded(s) => assert_eq!(
s.title, "FRESH",
"the fresh (newer-generation) result must win the retry race, not the stale one"
),
other => panic!("expected Loaded(FRESH), got {:?}", other),
}
}
#[test]
fn a_simultaneous_refresh_keeps_its_status_over_the_github_report() {
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let g_gen = request_github_issue(&mut app, 42);
let r_gen = app
.tasks
.request(TaskKind::RefreshWorktrees)
.expect("a cold refresh slot must hand out a generation");
let tx = app.task_result_sender();
tx.send(TaskMsg::GithubIssue(g_gen, 42, Ok(sample_issue(42)))).unwrap();
tx.send(TaskMsg::RefreshWorktrees(r_gen, Ok(Vec::new()))).unwrap();
app.drain_task_results();
assert!(
app.status.starts_with("refreshed —"),
"the refresh message must win a simultaneous completion (pre-#255 order), got {:?}",
app.status
);
}
#[test]
fn drain_applies_async_github_result() {
use gwm::tui::TaskMsg;
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let generation = request_github_issue(&mut app, 42);
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Loading));
assert!(app.is_github_loading(), "request must mark the app as loading");
app
.task_result_sender()
.send(TaskMsg::GithubIssue(generation, 42, Ok(sample_issue(42))))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "drain must report it applied a result");
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Loaded(_)));
assert!(!app.is_github_loading(), "no fetch should be inflight after draining");
}
#[test]
fn drain_drops_async_result_invalidated_mid_flight() {
use gwm::tui::TaskMsg;
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let generation = request_github_issue(&mut app, 42);
invalidate_github_for_test(&mut app);
app
.task_result_sender()
.send(TaskMsg::GithubIssue(generation, 42, Ok(sample_issue(42))))
.unwrap();
app.drain_task_results();
assert!(
matches!(app.issue_fetch_state(), GitHubFetchState::Idle),
"a result invalidated mid-flight must be dropped, not applied"
);
}
#[test]
fn drain_is_a_noop_with_no_pending_results() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
assert!(!app.drain_task_results(), "empty channel must report nothing applied");
}
#[test]
fn drain_does_not_report_when_only_stale_results_arrive() {
use gwm::tui::TaskMsg;
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let generation = request_github_issue(&mut app, 42);
invalidate_github_for_test(&mut app); app.status = "path: /somewhere/else".into();
app
.task_result_sender()
.send(TaskMsg::GithubIssue(generation, 42, Ok(sample_issue(42))))
.unwrap();
let applied = app.drain_task_results();
assert!(!applied, "a dropped stale result must not count as applied");
assert_eq!(
app.status, "path: /somewhere/else",
"a stale result must not overwrite the current status message"
);
}
#[test]
fn hint_context_prioritises_an_open_modal_over_pane_focus() {
use gwm::tui::{HintContext, View};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.focus_status(); app.view = View::Create;
assert_eq!(app.hint_context(), HintContext::Create);
app.view = View::Confirm;
assert_eq!(app.hint_context(), HintContext::Confirm);
app.view = View::CommandPalette;
assert_eq!(app.hint_context(), HintContext::CommandPalette);
app.view = View::List;
assert_eq!(app.hint_context(), HintContext::Status);
}
#[test]
fn apply_fetch_error_stores_error_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.apply_issue_fetch_result(Err("gh not found".into()));
match app.issue_fetch_state() {
GitHubFetchState::Error(msg) => assert!(msg.contains("gh"), "msg = {}", msg),
other => panic!("expected Error, got {:?}", other),
}
}
#[test]
fn refresh_link_invalidates_fetch_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.apply_issue_fetch_result(Err("e".into()));
app.refresh_link();
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
assert!(matches!(app.pr_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn next_resets_fetch_state_on_selection_change() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.worktrees.push(worktree_fixture("alt"));
app.list_state.select(Some(0));
app.apply_issue_fetch_result(Err("stale".into()));
app.next();
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
assert!(matches!(app.pr_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn prev_resets_fetch_state_on_selection_change() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.worktrees.push(worktree_fixture("alt"));
app.list_state.select(Some(0));
app.apply_pr_fetch_result(Err("stale".into()));
app.prev();
assert!(matches!(app.pr_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn first_resets_fetch_state_on_selection_change() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.worktrees.push(worktree_fixture("alt"));
app.list_state.select(Some(1));
app.apply_issue_fetch_result(Err("stale".into()));
app.first();
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn last_resets_fetch_state_on_selection_change() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.worktrees.push(worktree_fixture("alt"));
app.list_state.select(Some(0));
app.apply_issue_fetch_result(Err("stale".into()));
app.last();
assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn filter_clamping_resets_fetch_state_when_selection_moves() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.worktrees.push(worktree_fixture("zzz-unique"));
app.list_state.select(Some(1));
app.apply_issue_fetch_result(Err("stale".into()));
app.enter_filter();
app.filter_push_char('z'); assert!(matches!(app.issue_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn edit_worktree_failure_replaces_the_loading_status() {
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app
.tasks
.request(TaskKind::EditWorktree)
.expect("a fresh task generation");
app.status = TaskKind::EditWorktree.loading_label().into();
let tx = app.task_result_sender();
tx.send(TaskMsg::EditWorktree(
generation,
Err("target path already exists".into()),
))
.unwrap();
app.drain_task_results();
assert_ne!(
app.status,
TaskKind::EditWorktree.loading_label(),
"the loading label must be replaced after a failure"
);
assert!(
app.status.contains("target path already exists"),
"status must surface the rename failure: {}",
app.status
);
assert_eq!(
app.edit_failure.as_deref(),
Some("target path already exists"),
"the modal keeps the failure for inline display"
);
}
#[test]
fn fullscreen_child_stdout_routes_to_tty_only_when_gwm_stdout_is_captured() {
assert!(
gwm::tui::wants_child_stdout_on_tty(false),
"captured stdout (pipe) → child stdout must be rerouted to the tty"
);
assert!(
!gwm::tui::wants_child_stdout_on_tty(true),
"a real tty stdout → inherit, no reroute"
);
}
#[test]
fn reselect_by_path_maps_the_renamed_row_through_an_active_filter() {
let (_dir, mut app) = make_app();
app.worktrees = vec![
worktree_fixture("alpha"), worktree_fixture("beta-zz"), worktree_fixture("target-zz"), ];
app.enter_filter();
for c in "zz".chars() {
app.filter_push_char(c);
}
app.reselect_by_path(&PathBuf::from("/tmp/gwm-test/target-zz"));
assert_eq!(
app.list_state.selected(),
Some(1),
"selection must be the filtered slot, not the raw index"
);
assert_eq!(
app.selected().expect("a selection").name,
"target-zz",
"cursor must land on the renamed row through the filter"
);
}
#[test]
fn refresh_worktrees_resets_fetch_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.apply_pr_fetch_result(Err("stale".into()));
app.refresh().unwrap();
assert!(matches!(app.pr_fetch_state(), GitHubFetchState::Idle));
}
#[test]
fn refresh_github_status_message_reflects_partial_failure() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.apply_issue_fetch_result(Err("gh: connection refused".into()));
let pr = gwm::github::PrStatus {
number: 1,
title: "x".into(),
state: gwm::github::PrState::Open,
url: "https://example.test/pr".into(),
updated_at: "".into(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
};
app.apply_pr_fetch_result(Ok(pr));
app.report_github_refresh_status();
assert!(
!app.status.contains("refreshed"),
"status must not claim 'refreshed' on partial failure: {}",
app.status
);
assert!(
app.status.to_lowercase().contains("error") || app.status.to_lowercase().contains("fail"),
"status should mention failure: {}",
app.status
);
}
#[cfg(unix)]
#[test]
fn refresh_github_status_auto_detects_pr_for_unlinked_branch() {
use std::os::unix::fs::PermissionsExt;
let (dir, repo, mut app) = make_app_on_branch("detect-me");
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
app.refresh_link();
let write_gh = |path: &std::path::Path, n: u64| {
std::fs::write(
path,
format!(
"#!/bin/sh\n\
if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n\
printf '%s' '[{{\"number\":{n}}}]'\n\
elif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '%s' '{{\"number\":{n},\"title\":\"x\",\"state\":\"OPEN\",\"isDraft\":false,\"url\":\"https://example.test/pull/{n}\"}}'\n\
fi\n"
),
)
.unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
};
let gh_first = dir.path().join("fake-gh-128");
let gh_second = dir.path().join("fake-gh-200");
write_gh(&gh_first, 128);
write_gh(&gh_second, 200);
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &gh_first);
}
app.refresh_github_status();
assert_eq!(app.current_link().pr, Some(128));
assert_eq!(app.current_link().pr_source, LinkSource::Detected);
assert_eq!(
app.selected().map(|w| w.link.pr),
Some(Some(128)),
"the selected row snapshot must reflect the detected PR immediately"
);
unsafe {
std::env::set_var("GWM_GH", &gh_second);
}
app.refresh_github_status();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert_eq!(
app.current_link().pr,
Some(200),
"a detected PR must re-resolve on refresh"
);
assert_eq!(app.current_link().pr_source, LinkSource::Detected);
let persisted = gwm::github::read_link(&repo, "detect-me").unwrap();
assert_eq!(
persisted.pr,
Some(200),
"the detected PR must be persisted so the table read path sees it"
);
assert_eq!(persisted.pr_source, LinkSource::Detected);
}
#[test]
fn refresh_keeps_persisted_pr_when_no_remote_slug() {
let (_dir, repo, mut app) = make_app_on_branch("detect-me");
gwm::github::persist_detected_pr(&repo, "detect-me", 128).unwrap();
app.refresh_link();
assert_eq!(
app.current_link().pr,
Some(128),
"precondition: the persisted detection loads into memory"
);
app.refresh_github_status();
assert_eq!(
app.current_link().pr,
Some(128),
"a refresh that cannot probe must keep the persisted detection"
);
assert_eq!(app.current_link().pr_source, LinkSource::Detected);
}
#[cfg(unix)]
#[test]
fn refresh_keeps_persisted_pr_when_gh_detection_fails() {
use std::os::unix::fs::PermissionsExt;
let (dir, repo, mut app) = make_app_on_branch("detect-me");
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
app.refresh_link();
let gh_ok = dir.path().join("fake-gh-ok");
std::fs::write(
&gh_ok,
"#!/bin/sh\n\
if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n\
printf '%s' '[{\"number\":128}]'\n\
elif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '%s' '{\"number\":128,\"title\":\"x\",\"state\":\"OPEN\",\"isDraft\":false,\"url\":\"https://example.test/pull/128\"}'\n\
fi\n",
)
.unwrap();
let gh_fail = dir.path().join("fake-gh-fail");
std::fs::write(&gh_fail, "#!/bin/sh\nexit 1\n").unwrap();
for p in [&gh_ok, &gh_fail] {
let mut perms = std::fs::metadata(p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(p, perms).unwrap();
}
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &gh_ok);
}
app.refresh_github_status();
assert_eq!(app.current_link().pr, Some(128), "first refresh detects #128");
unsafe {
std::env::set_var("GWM_GH", &gh_fail);
}
app.refresh_github_status();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
let persisted = gwm::github::read_link(&repo, "detect-me").unwrap();
assert_eq!(
persisted.pr,
Some(128),
"a failed gh probe must not wipe the persisted detection"
);
assert_eq!(persisted.pr_source, LinkSource::Detected);
assert_eq!(
app.current_link().pr,
Some(128),
"the pane must keep the still-valid PR after a failed probe"
);
}
#[cfg(unix)]
#[test]
fn read_link_with_pr_detection_refreshes_a_persisted_detection() {
use std::os::unix::fs::PermissionsExt;
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
}
gwm::github::persist_detected_pr(&repo, "detect-me", 128).unwrap();
let gh = dir.path().join("fake-gh-200");
std::fs::write(
&gh,
"#!/bin/sh\n\
if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n\
printf '%s' '[{\"number\":200}]'\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&gh, perms).unwrap();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &gh);
}
let link = gwm::github::read_link_with_pr_detection(&repo, "detect-me", "kbrdn1/gwm-cli").unwrap();
let reconciled = gwm::github::read_link(&repo, "detect-me").unwrap();
gwm::github::link_pr(&repo, "detect-me", 61).unwrap();
let explicit = gwm::github::read_link_with_pr_detection(&repo, "detect-me", "kbrdn1/gwm-cli").unwrap();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert_eq!(
link.pr,
Some(200),
"live detection must override the stale persisted #128"
);
assert_eq!(link.pr_source, LinkSource::Detected);
assert_eq!(
reconciled.pr,
Some(200),
"the live detection must rewrite the persisted cache to the fresh number"
);
assert_eq!(explicit.pr, Some(61), "an explicit link still wins over live detection");
assert_eq!(explicit.pr_source, LinkSource::Explicit);
}
#[cfg(unix)]
#[test]
fn read_link_with_pr_detection_keeps_title_when_detected_pr_is_unchanged() {
use std::os::unix::fs::PermissionsExt;
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
}
gwm::github::persist_detected_pr(&repo, "detect-me", 128).unwrap();
gwm::github::persist_detected_pr_title(&repo, "detect-me", "Cached detected title").unwrap();
let gh = dir.path().join("fake-gh-128");
std::fs::write(
&gh,
"#!/bin/sh\n\
if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n\
printf '%s' '[{\"number\":128}]'\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&gh, perms).unwrap();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &gh);
}
let link = gwm::github::read_link_with_pr_detection(&repo, "detect-me", "kbrdn1/gwm-cli").unwrap();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert_eq!(link.pr, Some(128));
assert_eq!(link.pr_source, LinkSource::Detected);
assert_eq!(link.pr_title.as_deref(), Some("Cached detected title"));
}
#[cfg(unix)]
#[test]
fn read_link_with_pr_detection_clears_persisted_cache_when_pr_vanished() {
use std::os::unix::fs::PermissionsExt;
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("detect-me", &head, false).unwrap();
}
gwm::github::persist_detected_pr(&repo, "detect-me", 128).unwrap();
let gh = dir.path().join("fake-gh-empty");
std::fs::write(
&gh,
"#!/bin/sh\n\
if [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n\
printf '%s' '[]'\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&gh, perms).unwrap();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &gh);
}
let link = gwm::github::read_link_with_pr_detection(&repo, "detect-me", "kbrdn1/gwm-cli").unwrap();
let stored = gwm::github::read_link(&repo, "detect-me").unwrap();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert_eq!(link.pr, None, "a vanished PR resolves to no PR live");
assert_eq!(
stored.pr, None,
"the persisted cache must be cleared so no-fetch reads don't resurrect it"
);
}
#[test]
fn prepare_review_returns_none_when_no_review_configured() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let plan = app.prepare_review();
assert!(plan.is_none(), "no [review] config ⇒ no launcher plan");
let s = app.status.to_lowercase();
assert!(
s.contains("review") && (s.contains("not configured") || s.contains("not set") || s.contains("gwm.toml")),
"status bar must explain why R was inert: {}",
app.status
);
}
#[test]
fn prepare_review_skips_when_no_changes_and_flag_on() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.config.review.command = Some("lumen diff {base}..{head}".into());
app.config.review.fullscreen = Some(true);
let plan = app.prepare_review();
assert!(plan.is_none(), "no commits past base + skip_when_no_changes ⇒ skip");
let s = app.status.to_lowercase();
assert!(
s.contains("no changes"),
"status bar must say 'no changes': {}",
app.status
);
assert!(
app.status.contains("main") || app.status.contains("dev"),
"status should name the resolved base: {}",
app.status
);
}
#[test]
fn prepare_review_returns_plan_when_configured_and_diff_exists() {
let (dir, repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.config.review.command = Some("reviewer --base {base} --head {head}".into());
app.config.review.skip_when_no_changes = false;
app.config.review.default_base = Some("main".into());
let mut cfg = repo.config().unwrap();
let _ = cfg.remove("branch.feat/#42-tui-search.gwm-base");
let plan = app.prepare_review().expect("configured + no skip ⇒ plan present");
let argv = &plan.expanded.argv;
assert_eq!(argv[0], "reviewer");
assert_eq!(argv[1], "--base");
assert_eq!(argv[2], "main");
assert_eq!(argv[3], "--head");
assert_eq!(argv[4], "feat/#42-tui-search");
assert!(
common::paths_equal(&plan.cwd, dir.path()),
"plan.cwd = {} vs dir = {}",
plan.cwd.display(),
dir.path().display()
);
}
#[test]
fn prepare_review_respects_default_base_chain() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
gwm::launcher::write_gwm_base(&app.repo, "feat/#42-tui-search", "release-3.x").unwrap();
app.config.review.command = Some("echo {base}".into());
app.config.review.skip_when_no_changes = false;
app.config.review.default_base = Some("trunk".into());
let plan = app.prepare_review().expect("must resolve");
assert_eq!(
plan.expanded.argv,
vec!["echo", "release-3.x"],
"gwm-base must win over [review].default_base"
);
}
#[test]
fn prepare_git_tui_default_uses_lazygit() {
let (dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let plan = app.prepare_git_tui().expect("git_tui has a default");
let argv = &plan.expanded.argv;
assert_eq!(argv[0], "lazygit");
assert_eq!(argv[1], "-p");
assert!(
common::paths_equal(std::path::Path::new(&argv[2]), dir.path()),
"argv[2] = {} vs dir = {}",
argv[2],
dir.path().display()
);
assert!(plan.fullscreen, "lazygit defaults to fullscreen");
}
#[test]
fn prepare_git_tui_uses_user_command_when_set() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.config.git_tui.command = Some("gitui -d {path}".into());
app.config.git_tui.fullscreen = Some(false);
let plan = app.prepare_git_tui().expect("must resolve");
assert_eq!(plan.expanded.argv[0], "gitui");
assert_eq!(plan.expanded.argv[1], "-d");
assert!(!plan.fullscreen, "user opted out of fullscreen");
}
#[test]
fn refresh_github_status_message_celebrates_full_success() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let issue = gwm::github::IssueStatus {
number: 42,
title: "x".into(),
state: gwm::github::IssueState::Open,
url: "https://example.test".into(),
labels: vec![],
updated_at: "".into(),
};
app.apply_issue_fetch_result(Ok(issue));
app.report_github_refresh_status();
assert!(
app.status.to_lowercase().contains("refreshed") || app.status.to_lowercase().contains("ok"),
"all-green refresh should signal success: {}",
app.status
);
}
#[test]
fn branch_name_color_codes_synced_branch_as_green() {
let synced = BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 0,
behind: 0,
unknown: false,
};
assert_eq!(branch_name_color(&synced, &Theme::default()), Color::Green);
}
#[test]
fn branch_name_color_codes_dirty_branch_as_red() {
let dirty = BranchStatus {
is_dirty: true,
has_upstream: true,
ahead: 0,
behind: 0,
unknown: false,
};
assert_eq!(branch_name_color(&dirty, &Theme::default()), Color::Red);
}
#[test]
fn branch_name_color_codes_ahead_or_behind_as_yellow() {
let ahead = BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 3,
behind: 0,
unknown: false,
};
let behind = BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 0,
behind: 2,
unknown: false,
};
assert_eq!(branch_name_color(&ahead, &Theme::default()), Color::Yellow);
assert_eq!(branch_name_color(&behind, &Theme::default()), Color::Yellow);
}
#[test]
fn branch_name_color_codes_unpublished_branch_as_magenta() {
let unpublished = BranchStatus {
is_dirty: false,
has_upstream: false,
ahead: 0,
behind: 0,
unknown: false,
};
assert_eq!(branch_name_color(&unpublished, &Theme::default()), Color::Magenta);
}
#[test]
fn branch_name_color_codes_unknown_status_as_darkgray() {
let unknown = BranchStatus {
unknown: true,
..BranchStatus::default()
};
assert_eq!(branch_name_color(&unknown, &Theme::default()), Color::DarkGray);
}
#[test]
fn freshness_color_picks_green_for_recent_branches() {
assert_eq!(freshness_color(Duration::from_secs(0), &Theme::default()), Color::Green);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 3), &Theme::default()),
Color::Green
);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 6 + 3600 * 23), &Theme::default()),
Color::Green
);
}
#[test]
fn freshness_color_picks_yellow_for_one_to_four_week_branches() {
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 7), &Theme::default()),
Color::Yellow
);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 15), &Theme::default()),
Color::Yellow
);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 29 + 3600 * 23), &Theme::default()),
Color::Yellow
);
}
#[test]
fn freshness_color_picks_darkgray_for_stale_branches() {
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 30), &Theme::default()),
Color::DarkGray
);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 365), &Theme::default()),
Color::DarkGray
);
}
#[test]
fn pr_badge_color_maps_each_state_to_its_lazygit_palette() {
assert_eq!(pr_badge_color(PrState::Open, &Theme::default()), Color::Green);
assert_eq!(pr_badge_color(PrState::Draft, &Theme::default()), Color::DarkGray);
assert_eq!(pr_badge_color(PrState::Merged, &Theme::default()), Color::Magenta);
assert_eq!(pr_badge_color(PrState::Closed, &Theme::default()), Color::Red);
}
#[test]
fn issue_state_variants_compile() {
let _ = IssueState::Open;
let _ = IssueState::Closed;
}
use gwm::config::{TuiOpenConfig, TuiOpenMode};
use gwm::tui::OpenTarget;
#[test]
fn resolve_open_target_returns_none_when_nothing_selected() {
let (_dir, mut app) = make_app();
app.list_state.select(None);
assert!(app.resolve_open_target().is_none());
}
#[test]
fn resolve_open_target_defaults_to_shell_mode() {
let (_dir, app) = make_app();
let target = app
.resolve_open_target()
.expect("main worktree should always be selectable");
match target {
OpenTarget::Shell { path, command } => {
assert_eq!(path, app.worktrees[0].path);
assert!(!command.is_empty(), "shell command must never be empty");
}
other => panic!("expected Shell variant, got {:?}", other),
}
}
#[test]
fn resolve_open_target_honours_shell_cmd_override() {
let (_dir, mut app) = make_app();
app.config.tui.open = TuiOpenConfig {
mode: TuiOpenMode::Shell,
shell_cmd: Some("/sentinel/shell".into()),
editor_cmd: None,
};
match app.resolve_open_target().unwrap() {
OpenTarget::Shell { command, .. } => assert_eq!(command, "/sentinel/shell"),
other => panic!("expected Shell, got {:?}", other),
}
}
#[test]
fn resolve_open_target_uses_editor_mode_when_configured() {
let (_dir, mut app) = make_app();
app.config.tui.open = TuiOpenConfig {
mode: TuiOpenMode::Editor,
shell_cmd: None,
editor_cmd: Some("hx".into()),
};
match app.resolve_open_target().unwrap() {
OpenTarget::Editor { path, command } => {
assert_eq!(path, app.worktrees[0].path);
assert_eq!(command, "hx");
}
other => panic!("expected Editor, got {:?}", other),
}
}
#[test]
fn yank_selected_path_returns_path_for_selected_worktree() {
let (_dir, app) = make_app();
let path = app.yank_selected_path().expect("main worktree must be yankable");
assert_eq!(path, app.worktrees[0].path);
}
#[test]
fn yank_selected_path_returns_none_when_nothing_selected() {
let (_dir, mut app) = make_app();
app.list_state.select(None);
assert!(app.yank_selected_path().is_none());
}
fn marker_cells(line: &ratatui::text::Line<'_>) -> Vec<(String, Option<Color>)> {
line
.spans
.iter()
.map(|s| (s.content.as_ref().to_string(), s.style.fg))
.collect()
}
#[test]
fn table_marker_for_main_worktree_is_a_yellow_star() {
use gwm::github::BranchLink;
let mut w = worktree_fixture("main");
w.is_main = true;
w.link = BranchLink::empty();
let line = gwm::tui::table_marker(&w, &Theme::default());
assert_eq!(marker_cells(&line), vec![("★".to_string(), Some(Color::Yellow))]);
}
#[test]
fn table_marker_paints_green_issue_and_violet_pr_pastilles() {
use gwm::github::{BranchLink, LinkSource};
let mut w = worktree_fixture("feat-1");
w.is_main = false;
w.link = BranchLink {
issue: Some(42),
pr: Some(43),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::BranchName,
pr_source: LinkSource::Detected,
};
let line = gwm::tui::table_marker(&w, &Theme::default());
assert_eq!(
marker_cells(&line),
vec![
("●".to_string(), Some(Color::Green)), ("/".to_string(), Some(Color::DarkGray)), ("●".to_string(), Some(Color::Magenta)), ]
);
}
#[test]
fn table_marker_issue_only_leaves_the_pr_slot_as_dash() {
use gwm::github::{BranchLink, LinkSource};
let mut w = worktree_fixture("feat-1");
w.is_main = false;
w.link = BranchLink {
issue: Some(42),
pr: None,
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::BranchName,
pr_source: LinkSource::None,
};
let line = gwm::tui::table_marker(&w, &Theme::default());
let cells = marker_cells(&line);
assert_eq!(cells[0].1, Some(Color::Green), "issue dot green");
assert_eq!(cells[2].0, "-", "empty pr slot uses a dash");
assert_eq!(cells[2].1, Some(Color::White), "empty pr dash white");
}
#[test]
fn table_marker_issue_pastille_uses_loaded_closed_issue_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let mut w = worktree_fixture("feat-1");
w.branch = Some("feat/#42-tui-search".into());
w.link = app.current_link().clone();
app.worktrees = vec![w];
app.list_state.select(Some(0));
app.apply_issue_fetch_result(Ok(IssueStatus {
number: 42,
title: "Done".into(),
state: IssueState::Closed,
url: String::new(),
labels: vec![],
updated_at: String::new(),
}));
let theme = Theme::default();
let line = gwm::tui::table_marker(&app.worktrees[0], &theme);
let cells = marker_cells(&line);
assert_eq!(
cells[0].1,
Some(gwm::tui::issue_badge_color(IssueState::Closed, &theme)),
"closed issue dot should use the closed issue state colour"
);
}
#[test]
fn table_marker_pr_pastille_uses_loaded_closed_pr_state() {
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
let mut w = worktree_fixture("feat-1");
w.branch = Some("feat/#42-tui-search".into());
w.link = gwm::github::BranchLink {
issue: None,
pr: Some(61),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::None,
pr_source: LinkSource::Explicit,
};
app.github.link = w.link.clone();
app.worktrees = vec![w];
app.list_state.select(Some(0));
app.apply_pr_fetch_result(Ok(PrStatus {
number: 61,
title: "Closed".into(),
state: PrState::Closed,
url: String::new(),
updated_at: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
}));
let theme = Theme::default();
let line = gwm::tui::table_marker(&app.worktrees[0], &theme);
let cells = marker_cells(&line);
assert_eq!(
cells[2].1,
Some(gwm::tui::pr_badge_color(PrState::Closed, &theme)),
"closed PR dot should use the loaded PR state colour"
);
}
#[test]
fn table_marker_uses_persisted_issue_and_pr_state_on_startup() {
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-tui-search", &head, false).unwrap();
}
gwm::github::link_pr(&repo, "feat/#42-tui-search", 61).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-issue-state", "closed")
.unwrap();
cfg
.set_str("branch.feat/#42-tui-search.gwm-pr-state", "closed")
.unwrap();
}
repo.set_head("refs/heads/feat/#42-tui-search").unwrap();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
let theme = Theme::default();
let mut listed = app.worktrees[0].clone();
listed.is_main = false;
let cells = marker_cells(&gwm::tui::table_marker(&listed, &theme));
assert_eq!(
cells[0].1,
Some(gwm::tui::issue_badge_color(IssueState::Closed, &theme)),
"issue marker should reuse persisted issue state after restart"
);
assert_eq!(
cells[2].1,
Some(gwm::tui::pr_badge_color(PrState::Closed, &theme)),
"PR marker should reuse persisted PR state after restart"
);
}
#[test]
fn table_marker_pr_only_leaves_the_issue_slot_as_dash() {
use gwm::github::{BranchLink, LinkSource};
let mut w = worktree_fixture("feat-1");
w.is_main = false;
w.link = BranchLink {
issue: None,
pr: Some(43),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::None,
pr_source: LinkSource::Detected,
};
let line = gwm::tui::table_marker(&w, &Theme::default());
let cells = marker_cells(&line);
assert_eq!(cells[0].0, "-", "empty issue slot uses a dash");
assert_eq!(cells[0].1, Some(Color::White), "empty issue dash white");
assert_eq!(cells[2].1, Some(Color::Magenta), "pr dot violet");
}
#[test]
fn table_marker_unlinked_non_main_is_two_white_dashes() {
use gwm::github::BranchLink;
let mut w = worktree_fixture("feat-1");
w.is_main = false;
w.link = BranchLink::empty();
let line = gwm::tui::table_marker(&w, &Theme::default());
let cells = marker_cells(&line);
assert_eq!(cells[0].0, "-", "empty issue slot uses a dash");
assert_eq!(cells[0].1, Some(Color::White), "empty issue dash white");
assert_eq!(cells[1].0, "/", "muted separator between slots");
assert_eq!(cells[2].0, "-", "empty pr slot uses a dash");
assert_eq!(cells[2].1, Some(Color::White), "empty pr dash white");
}
#[test]
fn yank_candidates_for_current_platform_is_non_empty() {
assert!(
!gwm::tui::clipboard_candidates().is_empty(),
"clipboard candidates must include at least one tool for this OS"
);
}
#[test]
fn resolve_open_target_uses_finder_mode_for_legacy_behaviour() {
let (_dir, mut app) = make_app();
app.config.tui.open = TuiOpenConfig {
mode: TuiOpenMode::Finder,
shell_cmd: None,
editor_cmd: None,
};
match app.resolve_open_target().unwrap() {
OpenTarget::Finder { path } => assert_eq!(path, app.worktrees[0].path),
other => panic!("expected Finder, got {:?}", other),
}
}
use gwm::tui::build_sidebar_sections;
fn detailed_worktree_fixture() -> WorktreeInfo {
WorktreeInfo {
name: "api-rest".into(),
id: "api-rest".into(),
path: PathBuf::from("/Users/test/cc-worktree/api-rest"),
branch: Some("feat/#42-api-rest".into()),
head: Some("08d1029f1234567890abcdef".into()),
is_main: true,
is_locked: false,
is_prunable: false,
status: BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 0,
behind: 0,
unknown: false,
},
link: gwm::github::BranchLink::empty(),
issue_state: None,
pr_state: None,
age: None,
}
}
fn section_text(lines: &[ratatui::text::Line<'static>]) -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
#[test]
fn sidebar_sections_omit_commands_block() {
let w = detailed_worktree_fixture();
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let all = format!(
"{}\n{}\n{}",
section_text(§ions.worktree),
section_text(§ions.working_tree),
section_text(§ions.recent_commits),
);
assert!(
!all.contains("Commands"),
"the Commands cheat-sheet block must be removed (lives in ? help); got: {}",
all
);
assert!(
!all.contains("Bootstrap worktree"),
"help-overlay phrasing must not leak into the sidebar: {}",
all
);
assert!(
!all.contains("Toggle this sidebar"),
"help-overlay phrasing must not leak into the sidebar: {}",
all
);
}
#[test]
fn sidebar_sections_omit_inline_section_headers() {
let w = detailed_worktree_fixture();
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let all = format!(
"{}\n{}\n{}",
section_text(§ions.worktree),
section_text(§ions.working_tree),
section_text(§ions.recent_commits),
);
assert!(!all.contains("Basic Settings:"), "got: {}", all);
assert!(!all.contains("Recent commits:"), "got: {}", all);
assert!(!all.contains("Working tree:"), "got: {}", all);
}
#[test]
fn sidebar_worktree_section_is_compact_identity() {
let w = detailed_worktree_fixture();
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let text = section_text(§ions.worktree);
assert!(text.contains("api-rest"), "name on top line: {}", text);
assert!(text.contains("feat/#42-api-rest"), "branch shown: {}", text);
assert!(text.contains("08d1029"), "short head shown: {}", text);
assert!(
text.contains("synced") || text.contains("✓"),
"synced state badge shown: {}",
text
);
assert!(
text.contains("main") || text.contains("★"),
"main badge shown: {}",
text
);
}
#[test]
fn sidebar_worktree_section_short_enough_for_compact_layout() {
let w = detailed_worktree_fixture();
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
assert!(
sections.worktree.len() <= 5,
"compact worktree block must stay ≤5 lines (target 4), got {}: {:?}",
sections.worktree.len(),
sections.worktree.iter().map(section_text_single).collect::<Vec<_>>()
);
}
fn section_text_single(l: &ratatui::text::Line<'static>) -> String {
l.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn sidebar_diff_line_renders_counts_in_theme_roles() {
let w = detailed_worktree_fixture();
let theme = Theme {
untracked: Color::Rgb(10, 20, 30),
prunable: Color::Rgb(40, 50, 60),
..Theme::default()
};
let diff = gwm::worktree::DiffLineStat {
insertions: 12,
deletions: 4,
};
let sections = build_sidebar_sections(&w, gwm::tui::state::sidebar::SidebarMode::Commits, Some(diff), &theme);
let diff_line = sections
.worktree
.iter()
.find(|l| section_text_single(l).contains("Diff"))
.expect("identity card must carry a Diff line when a stat is supplied");
let ins = diff_line.spans.iter().find(|s| s.content.contains("+12")).unwrap();
assert_eq!(
ins.style.fg,
Some(Color::Rgb(10, 20, 30)),
"insertions wear `untracked`"
);
let del = diff_line.spans.iter().find(|s| s.content.contains("-4")).unwrap();
assert_eq!(del.style.fg, Some(Color::Rgb(40, 50, 60)), "deletions wear `prunable`");
}
#[test]
fn sidebar_diff_line_absent_for_empty_or_missing_stat() {
let w = detailed_worktree_fixture();
for diff in [None, Some(gwm::worktree::DiffLineStat::default())] {
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
diff,
&Theme::default(),
);
assert!(
!sections
.worktree
.iter()
.any(|l| section_text_single(l).contains("Diff")),
"no Diff line should render for {diff:?}"
);
}
}
use gwm::tui::working_tree_status_line;
fn filename_span_fg(line: &ratatui::text::Line<'static>, needle: &str) -> Option<Color> {
line
.spans
.iter()
.find(|s| s.content.contains(needle))
.unwrap_or_else(|| panic!("no span carrying {:?} in {:?}", needle, section_text_single(line)))
.style
.fg
}
#[test]
fn working_tree_status_line_preserves_raw_text() {
for raw in [
"A staged.rs",
"AM both.rs",
" M tracked.rs",
" D gone.rs",
"?? untracked.rs",
"R old.rs -> new.rs",
] {
assert_eq!(
section_text_single(&working_tree_status_line(raw, &Theme::default())),
raw,
"raw text preserved for {:?}",
raw
);
}
}
#[test]
fn working_tree_status_line_added_is_green() {
let line = working_tree_status_line("A staged.rs", &Theme::default());
assert_eq!(line.spans[0].style.fg, Some(Color::Green), "added code → green");
assert_eq!(
filename_span_fg(&line, "staged.rs"),
Some(Color::Green),
"added filename → green"
);
}
#[test]
fn working_tree_status_line_modified_is_yellow() {
let line = working_tree_status_line(" M tracked.rs", &Theme::default());
assert_eq!(line.spans[0].style.fg, Some(Color::Yellow), "modified code → yellow");
assert_eq!(
filename_span_fg(&line, "tracked.rs"),
Some(Color::Yellow),
"modified filename → yellow"
);
}
#[test]
fn working_tree_status_line_deleted_is_red() {
for raw in ["D gone.rs", " D gone.rs"] {
let line = working_tree_status_line(raw, &Theme::default());
assert_eq!(line.spans[0].style.fg, Some(Color::Red), "deleted code → red: {raw:?}");
assert_eq!(
filename_span_fg(&line, "gone.rs"),
Some(Color::Red),
"deleted filename → red: {raw:?}"
);
}
}
#[test]
fn working_tree_status_line_untracked_is_green() {
let line = working_tree_status_line("?? untracked.rs", &Theme::default());
assert_eq!(line.spans[0].style.fg, Some(Color::Green), "untracked code → green");
assert_eq!(
filename_span_fg(&line, "untracked.rs"),
Some(Color::Green),
"untracked filename → green"
);
}
#[test]
fn working_tree_status_line_handles_multibyte_leading_chars() {
let raw = "éM café.rs"; let line = working_tree_status_line(raw, &Theme::default());
assert_eq!(
section_text_single(&line),
raw,
"multi-byte text preserved without panic"
);
}
#[test]
fn working_tree_status_line_added_then_modified_is_green() {
let line = working_tree_status_line("AM both.rs", &Theme::default());
assert_eq!(line.spans[0].style.fg, Some(Color::Green), "AM (created wins) → green");
assert_eq!(
filename_span_fg(&line, "both.rs"),
Some(Color::Green),
"AM filename → green"
);
}
#[test]
fn sidebar_worktree_section_skips_irrelevant_badges() {
let mut w = detailed_worktree_fixture();
w.is_main = false;
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let text = section_text(§ions.worktree);
assert!(
!text.contains("★ main"),
"non-main worktree must not show ★ main: {}",
text
);
assert!(
!text.contains("locked"),
"unlocked worktree must not show locked badge: {}",
text
);
assert!(
!text.contains("prunable"),
"non-prunable worktree must not show prunable badge: {}",
text
);
}
#[test]
fn sidebar_worktree_badge_uses_divergence_sigil_when_ahead() {
let mut w = detailed_worktree_fixture();
w.status = BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 2,
behind: 0,
unknown: false,
};
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let badge = section_text_single(§ions.worktree[2]);
assert!(
!badge.contains("✓"),
"ahead-only branch must not display the synced/clean ✓ sigil: {}",
badge
);
assert!(badge.contains("↑2"), "ahead label must still be visible: {}", badge);
}
#[test]
fn sidebar_worktree_badge_uses_divergence_sigil_when_behind() {
let mut w = detailed_worktree_fixture();
w.status = BranchStatus {
is_dirty: false,
has_upstream: true,
ahead: 0,
behind: 3,
unknown: false,
};
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let badge = section_text_single(§ions.worktree[2]);
assert!(
!badge.contains("✓"),
"behind-only branch must not display the synced/clean ✓ sigil: {}",
badge
);
assert!(badge.contains("↓3"), "behind label must still be visible: {}", badge);
}
#[test]
fn sidebar_worktree_badge_keeps_check_sigil_when_synced() {
let w = detailed_worktree_fixture();
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
let badge = section_text_single(§ions.worktree[2]);
assert!(badge.contains("✓"), "synced branch must keep the ✓ sigil: {}", badge);
assert!(badge.contains("synced"), "label must still say synced: {}", badge);
}
use gwm::tui::tilde_compress_with_home;
#[test]
fn tilde_compress_does_not_slice_across_path_boundaries() {
let home = std::path::Path::new("/home/al");
assert_eq!(
tilde_compress_with_home("/home/alice/repo", home),
"/home/alice/repo",
"must not slice across the `alice` directory name"
);
}
#[test]
fn tilde_compress_compresses_exact_home_match() {
let home = std::path::Path::new("/home/alice");
assert_eq!(tilde_compress_with_home("/home/alice", home), "~");
assert_eq!(tilde_compress_with_home("/home/alice/repo", home), "~/repo");
assert_eq!(tilde_compress_with_home("/home/alice/repo/sub", home), "~/repo/sub");
}
#[test]
fn tilde_compress_falls_back_when_path_outside_home() {
let home = std::path::Path::new("/home/alice");
assert_eq!(tilde_compress_with_home("/var/log/x", home), "/var/log/x");
assert_eq!(tilde_compress_with_home("/home/alicent/x", home), "/home/alicent/x");
}
use gwm::tui::{issue_summary_line, pr_summary_line};
fn line_visible_width(line: &ratatui::text::Line<'static>) -> usize {
line.spans.iter().map(|s| s.content.chars().count()).sum()
}
#[test]
fn github_status_idle_body_does_not_render_fetch_prompt() {
let (_dir, _repo, app) = make_app_on_branch("feat/#42-tui-search");
let lines = gwm::tui::github_status_lines(&app, 80);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref().to_string()))
.collect::<Vec<_>>()
.join(" ");
assert!(
!text.contains("press "),
"fetch prompt should not render inside the Issue/PR body: {text}"
);
}
#[test]
fn github_status_no_link_hint_uses_the_real_link_prompt_chord() {
let (_dir, _repo, app) = make_app_on_branch("scratch");
let text: String = gwm::tui::github_status_lines(&app, 120)
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref().to_string()))
.collect::<Vec<_>>()
.join("");
assert!(text.contains("no link"), "the no-link hint should render: {text}");
assert!(
text.contains("press i to link"),
"hint must use LinkPrompt's real chord, not the stale `L`: {text}"
);
}
#[test]
fn github_status_loading_uses_the_animated_spinner_frame() {
use gwm::tui::FetchKey;
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-tui-search");
app.github.mark_loading(FetchKey::Issue(42));
let first = gwm::tui::github_status_lines(&app, 80)
.into_iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
app.spinner.tick();
let second = gwm::tui::github_status_lines(&app, 80)
.into_iter()
.map(|line| spans_to_text(&line.spans))
.collect::<Vec<_>>()
.join("\n");
assert!(first.contains("loading"), "loading label missing: {first:?}");
assert_ne!(first, second, "loading rows should animate with the App spinner");
}
#[test]
fn issue_summary_line_truncates_loaded_state_to_budget() {
let status = gwm::github::IssueStatus {
number: 828,
title:
"Stats: subscriptions distribution across schools and individual customers (very long title to force truncation)"
.into(),
state: gwm::github::IssueState::Open,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let line = issue_summary_line(
828,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Loaded(status),
30,
&Theme::default(),
);
let width = line_visible_width(&line);
assert!(
width <= 30,
"loaded issue line must fit in 30 cols, got {}: {:?}",
width,
line.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<_>>()
);
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(joined.ends_with('…'), "expected trailing ellipsis: {}", joined);
}
#[test]
fn pr_summary_line_truncates_loaded_state_to_budget() {
let status = gwm::github::PrStatus {
number: 70,
title: "feat(tui): redesign Details sidebar with bordered subsections and four cards".into(),
state: gwm::github::PrState::Open,
url: String::new(),
checks_passed: 3,
checks_total: 3,
ci: CiState::Passing,
updated_at: String::new(),
};
let line = pr_summary_line(
70,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Loaded(status),
35,
&Theme::default(),
);
let width = line_visible_width(&line);
assert!(
width <= 35,
"loaded PR line must fit in 35 cols, got {}: {:?}",
width,
line.spans.iter().map(|s| s.content.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn issue_summary_line_keeps_short_title_intact() {
let status = gwm::github::IssueStatus {
number: 1,
title: "short".into(),
state: gwm::github::IssueState::Open,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let line = issue_summary_line(
1,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Loaded(status),
80,
&Theme::default(),
);
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.contains("short"),
"short title must not be truncated: {}",
joined
);
assert!(
!joined.contains('…'),
"no ellipsis when budget exceeds content: {}",
joined
);
}
#[test]
fn issue_summary_line_truncates_error_state_to_budget() {
let line = issue_summary_line(
42,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Error(
"gh: API rate limit exceeded for user, retry after 60s with exponential backoff please".into(),
),
30,
&Theme::default(),
);
let width = line_visible_width(&line);
assert!(width <= 30, "error line must fit in 30 cols, got {}", width);
}
fn span_with<'a>(line: &'a ratatui::text::Line<'a>, needle: &str) -> Option<&'a ratatui::text::Span<'a>> {
line.spans.iter().find(|s| s.content.contains(needle))
}
#[test]
fn issue_summary_line_leads_with_the_issue_icon() {
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Idle,
80,
&Theme::default(),
);
assert!(
line.spans[0].content.contains(gwm::tui::ISSUE_ICON),
"issue pane line must lead with the issue nerdfont glyph: {:?}",
line.spans[0].content
);
}
#[test]
fn issue_summary_line_icon_has_trailing_space_only() {
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Idle,
80,
&Theme::default(),
);
assert_eq!(
line.spans[0].content.as_ref(),
format!("{} ", gwm::tui::ISSUE_ICON),
"issue icon segment should leave two spaces after the glyph only"
);
}
#[test]
fn issue_summary_line_loaded_icon_uses_issue_state_color() {
let status = gwm::github::IssueStatus {
number: 7,
title: "x".into(),
state: gwm::github::IssueState::Closed,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let theme = Theme::default();
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Loaded(status),
80,
&theme,
);
assert_eq!(
line.spans[0].style.fg,
Some(gwm::tui::issue_badge_color(gwm::github::IssueState::Closed, &theme)),
"loaded issue icon should reuse the issue state badge role"
);
}
#[test]
fn issue_summary_line_idle_icon_stays_muted() {
let theme = Theme::default();
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Idle,
80,
&theme,
);
assert_eq!(
line.spans[0].style.fg,
Some(theme.muted),
"idle issue icon stays neutral"
);
}
#[test]
fn pr_summary_line_leads_with_the_pr_icon() {
let status = gwm::github::PrStatus {
number: 9,
title: "x".into(),
state: gwm::github::PrState::Open,
url: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
updated_at: String::new(),
};
let line = pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Loaded(status),
80,
&Theme::default(),
);
assert!(
line.spans[0].content.contains(gwm::tui::PR_ICON),
"pr pane line must lead with the pr nerdfont glyph: {:?}",
line.spans[0].content
);
}
#[test]
fn pr_summary_line_icon_has_trailing_space_only() {
let line = pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Idle,
80,
&Theme::default(),
);
assert_eq!(
line.spans[0].content.as_ref(),
format!("{} ", gwm::tui::PR_ICON),
"PR icon segment should leave two spaces after the glyph only"
);
}
#[test]
fn pr_summary_line_loaded_icon_uses_pr_state_color() {
let status = gwm::github::PrStatus {
number: 9,
title: "x".into(),
state: gwm::github::PrState::Merged,
url: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
updated_at: String::new(),
};
let theme = Theme::default();
let line = pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Loaded(status),
80,
&theme,
);
assert_eq!(
line.spans[0].style.fg,
Some(gwm::tui::pr_badge_color(gwm::github::PrState::Merged, &theme)),
"loaded PR icon should reuse the PR state badge role"
);
}
#[test]
fn pr_summary_line_loaded_renders_ci_indicator_when_checks_present() {
let status = gwm::github::PrStatus {
number: 9,
title: "x".into(),
state: gwm::github::PrState::Open,
url: String::new(),
checks_passed: 1,
checks_total: 2,
ci: gwm::github::CiState::Failing,
updated_at: String::new(),
};
let theme = Theme::default();
let line = pr_summary_line(
9,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Loaded(status),
80,
&theme,
);
let ci = span_with(&line, "CI").expect("a CI indicator span");
assert!(
ci.content.contains("failing") && ci.content.contains("1/2"),
"CI indicator must carry the failing label and count, got {:?}",
ci.content
);
assert_eq!(
ci.style.fg,
Some(theme.prunable),
"a failing CI indicator must paint with the prunable (red) role"
);
}
#[test]
fn pr_summary_line_loaded_omits_ci_indicator_when_no_checks() {
let status = gwm::github::PrStatus {
number: 9,
title: "x".into(),
state: gwm::github::PrState::Open,
url: String::new(),
checks_passed: 0,
checks_total: 0,
ci: gwm::github::CiState::None,
updated_at: String::new(),
};
let line = pr_summary_line(
9,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Loaded(status),
80,
&Theme::default(),
);
let joined: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!joined.contains("CI"),
"a PR with no checks must not render any CI indicator: {}",
joined
);
}
#[test]
fn ci_indicator_maps_states_to_status_roles() {
let theme = Theme::default();
assert!(gwm::tui::ci_indicator(gwm::github::CiState::None, 0, 0, &theme).is_none());
let (txt, col) = gwm::tui::ci_indicator(gwm::github::CiState::Passing, 9, 9, &theme).unwrap();
assert!(txt.contains("passing") && txt.contains("9/9"));
assert_eq!(col, theme.clean);
let (txt, col) = gwm::tui::ci_indicator(gwm::github::CiState::Failing, 7, 9, &theme).unwrap();
assert!(txt.contains("failing") && txt.contains("7/9"));
assert_eq!(col, theme.prunable);
let (txt, col) = gwm::tui::ci_indicator(gwm::github::CiState::Running, 8, 9, &theme).unwrap();
assert!(txt.contains("running") && txt.contains("8/9"));
assert_eq!(col, theme.dirty);
}
#[test]
fn pr_summary_line_renders_detected_source_as_a_reverse_video_chip() {
use ratatui::style::Modifier;
let status = gwm::github::PrStatus {
number: 9,
title: "x".into(),
state: gwm::github::PrState::Open,
url: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
updated_at: String::new(),
};
let line = pr_summary_line(
9,
gwm::github::LinkSource::Detected,
&GitHubFetchState::Loaded(status),
80,
&Theme::default(),
);
let chip = span_with(&line, "detected").expect("a 'detected' source chip span");
assert!(
chip.style.add_modifier.contains(Modifier::REVERSED),
"the source chip must use the version-badge reverse-video treatment"
);
}
#[test]
fn issue_summary_line_renders_auto_source_as_a_reverse_video_chip() {
use ratatui::style::Modifier;
let line = issue_summary_line(
7,
gwm::github::LinkSource::BranchName,
&GitHubFetchState::Idle,
80,
&Theme::default(),
);
let chip = span_with(&line, "auto").expect("an 'auto' source chip span");
assert!(
chip.style.add_modifier.contains(Modifier::REVERSED),
"the source chip must use the version-badge reverse-video treatment"
);
}
#[test]
fn explicit_link_renders_no_source_chip() {
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Idle,
80,
&Theme::default(),
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!text.contains("auto"),
"explicit link must not show an auto chip: {text}"
);
assert!(
!text.contains("detected"),
"explicit link must not show a detected chip: {text}"
);
}
#[test]
fn issue_summary_line_state_badge_is_a_reverse_video_chip() {
use ratatui::style::Modifier;
let status = gwm::github::IssueStatus {
number: 7,
title: "x".into(),
state: gwm::github::IssueState::Open,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let line = issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&GitHubFetchState::Loaded(status),
80,
&Theme::default(),
);
let chip = span_with(&line, "open").expect("an 'open' state chip span");
assert!(
chip.style.add_modifier.contains(Modifier::REVERSED),
"the state badge must use the version-badge reverse-video treatment"
);
}
use gwm::tui::{recent_commits_lines, RECENT_COMMITS_LIMIT};
fn add_commits(repo: &git2::Repository, count: usize) {
use git2::Signature;
let sig = Signature::now("gwm-test", "gwm@test").unwrap();
for i in 0..count {
let parent = repo.head().unwrap().peel_to_commit().unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo
.commit(Some("HEAD"), &sig, &sig, &format!("commit-{}", i), &tree, &[&parent])
.unwrap();
}
}
fn worktree_pointing_at_dir(dir: &std::path::Path) -> WorktreeInfo {
WorktreeInfo {
name: "test".into(),
id: "test".into(),
path: dir.to_path_buf(),
branch: Some("main".into()),
head: None,
is_main: true,
is_locked: false,
is_prunable: false,
status: BranchStatus::default(),
link: gwm::github::BranchLink::empty(),
issue_state: None,
pr_state: None,
age: None,
}
}
#[test]
fn recent_commits_lines_respects_limit_when_repo_has_more() {
let (dir, repo) = init_repo();
add_commits(&repo, 14); let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 5, &Theme::default());
assert_eq!(
lines.len(),
5,
"limit=5 must produce exactly 5 lines, got {}",
lines.len()
);
}
#[test]
fn recent_commits_lines_returns_all_when_under_limit() {
let (dir, _repo) = init_repo();
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 100, &Theme::default());
assert_eq!(
lines.len(),
1,
"init_repo has 1 commit, asking for 100 should still return 1, got {}",
lines.len()
);
}
#[test]
fn recent_commits_lines_reuses_cached_rows_for_unchanged_head() {
let (dir, repo) = init_repo();
add_commits(&repo, 3);
let mut w = worktree_pointing_at_dir(dir.path());
w.head = Some(repo.head().unwrap().target().unwrap().to_string());
let first = recent_commits_lines(&w, 4, &Theme::default());
let first_text: Vec<String> = first
.iter()
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
.collect();
drop(repo);
std::fs::rename(dir.path().join(".git"), dir.path().join(".git.hidden")).unwrap();
let second = recent_commits_lines(&w, 4, &Theme::default());
let second_text: Vec<String> = second
.iter()
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect())
.collect();
assert_eq!(
second_text, first_text,
"unchanged head+limit should reuse cached recent commit rows instead of re-reading the repo"
);
}
#[test]
fn recent_commits_cache_is_scoped_to_worktree_path() {
let (dir, repo) = init_repo();
let mut cached = worktree_pointing_at_dir(dir.path());
cached.head = Some(repo.head().unwrap().target().unwrap().to_string());
let first = recent_commits_lines(&cached, 1, &Theme::default());
let first_text: String = first[0].spans.iter().map(|span| span.content.as_ref()).collect();
let other = tempfile::TempDir::new().unwrap();
let mut same_oid_different_path = worktree_pointing_at_dir(other.path());
same_oid_different_path.head = cached.head.clone();
let second = recent_commits_lines(&same_oid_different_path, 1, &Theme::default());
let second_text: String = second[0].spans.iter().map(|span| span.content.as_ref()).collect();
assert!(
second_text.starts_with("! "),
"same OID in a different worktree path must miss the cache, got: {}",
second_text
);
assert_ne!(
second_text, first_text,
"recent commit cache must not leak rows across repositories that share an OID"
);
}
#[test]
#[allow(clippy::assertions_on_constants)] fn recent_commits_default_limit_fills_modern_terminal_heights() {
assert!(
RECENT_COMMITS_LIMIT >= 50,
"RECENT_COMMITS_LIMIT must be ≥ 50 to fill a typical sidebar, got {}",
RECENT_COMMITS_LIMIT
);
}
#[test]
fn build_sidebar_sections_fetches_up_to_default_recent_commits_limit() {
let (dir, repo) = init_repo();
add_commits(&repo, 30); let w = worktree_pointing_at_dir(dir.path());
let sections = build_sidebar_sections(
&w,
gwm::tui::state::sidebar::SidebarMode::Commits,
None,
&Theme::default(),
);
assert_eq!(
sections.recent_commits.len(),
31,
"expected all 31 commits to be cached (default limit ≥ 50 ≥ 31), got {}",
sections.recent_commits.len()
);
}
use gwm::tui::{author_initials, COMMIT_HASH_DISPLAY_LEN};
#[test]
fn author_initials_two_word_name_picks_first_letters_of_each() {
assert_eq!(author_initials("Kylian Bardini"), "KB");
assert_eq!(author_initials("Jesse Duffield"), "JD");
}
#[test]
fn author_initials_single_word_takes_first_two_chars() {
assert_eq!(author_initials("Linus"), "Li");
assert_eq!(author_initials("kb"), "kb");
}
#[test]
fn author_initials_three_or_more_words_only_uses_first_two() {
assert_eq!(author_initials("Jean-Paul Marie Dupont"), "JM");
}
#[test]
fn author_initials_strips_leading_whitespace() {
assert_eq!(author_initials(" Kylian Bardini"), "KB");
}
#[test]
fn author_initials_empty_returns_empty() {
assert_eq!(author_initials(""), "");
assert_eq!(author_initials(" "), "");
}
#[test]
fn author_initials_takes_first_unicode_scalar_per_token() {
assert_eq!(author_initials("🦀 Crab"), "🦀C");
assert_eq!(
author_initials("🇫🇷 Bardini"),
"🇫B",
"two-scalar grapheme cluster (flag) is intentionally split per scalar"
);
}
fn add_merge_commit(repo: &git2::Repository) -> git2::Oid {
use git2::Signature;
let sig = Signature::now("gwm-test", "gwm@test").unwrap();
let base = repo.head().unwrap().peel_to_commit().unwrap();
let branch_name = "tmp-side";
repo.branch(branch_name, &base, false).unwrap();
let side_oid = {
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo
.commit(
Some(&format!("refs/heads/{}", branch_name)),
&sig,
&sig,
"side",
&tree,
&[&base],
)
.unwrap()
};
let side = repo.find_commit(side_oid).unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo
.commit(
Some("HEAD"),
&sig,
&sig,
"merge: side into trunk",
&tree,
&[&base, &side],
)
.unwrap()
}
#[test]
fn commit_row_carries_parent_hashes() {
let (dir, repo) = init_repo();
add_merge_commit(&repo); let rows = gwm::worktree::git_log_with_author(dir.path(), 10).unwrap();
assert!(!rows.is_empty(), "expected at least 1 commit");
assert_eq!(
rows[0].parents.len(),
2,
"HEAD is a merge commit and must surface both parents, got {:?}",
rows[0].parents
);
let seed = rows
.iter()
.find(|r| r.subject == "init")
.expect("seed commit must be in log");
assert!(
seed.parents.is_empty(),
"seed commit has no parents, got {:?}",
seed.parents
);
}
#[test]
fn recent_commits_line_marks_merge_commit_with_bullseye() {
let (dir, repo) = init_repo();
add_merge_commit(&repo);
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 10, &Theme::default());
let merge = lines
.iter()
.find(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined.contains("merge: side into trunk")
})
.expect("merge row must be present");
let joined: String = merge.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.contains('◎'),
"merge row must carry the ◎ bullseye marker, got: {}",
joined
);
}
use gwm::tui::commit_graph::{box_drawing_chars, build_pipe_sets, render_commits, render_pipe_set, test_row, PipeKind};
fn spans_to_text(spans: &[ratatui::text::Span<'static>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
#[allow(clippy::type_complexity)]
fn graph_glyph_table_matches_lazygit_truth_table() {
let cases: &[((bool, bool, bool, bool), (char, char))] = &[
((true, true, true, true), ('│', '─')),
((true, true, true, false), ('│', ' ')),
((true, true, false, true), ('│', '─')),
((true, true, false, false), ('│', ' ')),
((true, false, true, true), ('┴', '─')),
((true, false, true, false), ('╯', ' ')),
((true, false, false, true), ('╰', '─')),
((true, false, false, false), ('╵', ' ')),
((false, true, true, true), ('┬', '─')),
((false, true, true, false), ('╮', ' ')),
((false, true, false, true), ('╭', '─')),
((false, true, false, false), ('╷', ' ')),
((false, false, true, true), ('─', '─')),
((false, false, true, false), ('─', ' ')),
((false, false, false, true), ('╶', '─')),
((false, false, false, false), (' ', ' ')),
];
for &((u, d, l, r), expected) in cases {
assert_eq!(
box_drawing_chars(u, d, l, r),
expected,
"case ({}, {}, {}, {}) — expected {:?}",
u,
d,
l,
r,
expected
);
}
}
#[test]
fn graph_linear_history_emits_single_column_circles() {
let rows = vec![test_row("c", &["b"]), test_row("b", &["a"]), test_row("a", &[])];
let graphs = render_commits(&rows, &Theme::default());
assert_eq!(graphs.len(), 3);
for (idx, g) in graphs.iter().enumerate() {
let text = spans_to_text(g);
assert!(
text.contains('○'),
"linear history row {} must carry a ○ node, got {:?}",
idx,
text
);
assert!(
!text.contains('◎'),
"linear history row {} must NOT carry a ◎ merge node, got {:?}",
idx,
text
);
}
}
#[test]
fn graph_merge_commit_carries_bullseye_and_branch_corners() {
let rows = vec![test_row("c", &["a", "b"]), test_row("b", &["a"]), test_row("a", &[])];
let graphs = render_commits(&rows, &Theme::default());
let merge_text = spans_to_text(&graphs[0]);
assert!(merge_text.contains('◎'), "merge row must carry ◎, got {:?}", merge_text);
assert!(
merge_text.contains('╮') || merge_text.contains('─'),
"merge row must carry a corner / horizontal stroke into the new branch column, got {:?}",
merge_text
);
}
#[test]
fn graph_pipe_set_first_commit_seeds_starts_pipe() {
let rows = vec![test_row("a", &["b"])];
let pipes = build_pipe_sets(&rows);
assert_eq!(pipes.len(), 1);
assert!(
pipes[0]
.iter()
.any(|p| p.kind == PipeKind::Starts && p.from_hash == test_row("a", &[]).hash),
"first row must contain a STARTS pipe whose from_hash is the commit itself, got {:?}",
pipes[0]
);
}
#[test]
fn graph_pipe_set_merge_commit_emits_extra_starts_per_parent() {
let rows = vec![test_row("c", &["a", "b"]), test_row("b", &["a"]), test_row("a", &[])];
let pipes = build_pipe_sets(&rows);
let row0 = &pipes[0];
let starts: Vec<_> = row0.iter().filter(|p| p.kind == PipeKind::Starts).collect();
assert_eq!(
starts.len(),
2,
"merge row must emit 2 STARTS pipes (one per parent), got {} ({:?})",
starts.len(),
starts
);
}
#[test]
fn graph_render_pipe_set_empty_input_returns_empty() {
let graphs = render_commits(&[], &Theme::default());
assert!(graphs.is_empty());
}
#[test]
fn graph_row_width_is_deterministic_on_commit_list() {
let rows = vec![test_row("c", &["b"]), test_row("b", &["a"]), test_row("a", &[])];
let graphs = render_commits(&rows, &Theme::default());
for g in &graphs {
let text = spans_to_text(g);
let chars = text.chars().count();
assert!(
(1..=8).contains(&chars),
"linear-history row must render in ≤ 8 chars, got {} ({:?})",
chars,
text
);
}
}
#[test]
fn graph_render_pipe_set_handles_single_pipe_starts() {
use gwm::tui::commit_graph::Pipe;
let from = test_row("a", &[]);
let to = test_row("b", &[]);
let pipes = vec![Pipe {
from_pos: 0,
to_pos: 0,
from_hash: from.hash,
to_hash: to.hash,
kind: PipeKind::Starts,
}];
let spans = render_pipe_set(&pipes, &Theme::default());
let text = spans_to_text(&spans);
assert!(text.starts_with('○'), "expected ○ glyph at column 0, got {:?}", text);
}
#[test]
fn recent_commits_line_marks_normal_commit_with_open_circle() {
let (dir, _repo) = init_repo();
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 1, &Theme::default());
let joined: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.contains('○'),
"non-merge row must carry the ○ marker, got: {}",
joined
);
assert!(
!joined.contains('◎'),
"non-merge row must NOT carry the ◎ marker, got: {}",
joined
);
}
#[test]
fn recent_commits_line_starts_with_short_hash() {
let (dir, _repo) = init_repo();
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 1, &Theme::default());
assert_eq!(lines.len(), 1, "init_repo should produce 1 commit");
let head_span = lines[0]
.spans
.first()
.expect("commit row must carry at least the hash span");
assert_eq!(
head_span.content.chars().count(),
COMMIT_HASH_DISPLAY_LEN,
"expected hash span of {} chars, got {:?}",
COMMIT_HASH_DISPLAY_LEN,
head_span.content
);
assert!(
head_span.content.chars().all(|c| c.is_ascii_hexdigit()),
"expected hex hash, got {:?}",
head_span.content
);
}
#[test]
fn recent_commits_line_includes_author_initials_after_hash() {
let (dir, _repo) = init_repo();
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 1, &Theme::default());
let joined: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.contains("gw"),
"expected 'gw' initials from 'gwm-test', got {:?}",
joined
);
}
#[test]
fn recent_commits_line_carries_subject_unclipped() {
let (dir, _repo) = init_repo();
let w = worktree_pointing_at_dir(dir.path());
let lines = recent_commits_lines(&w, 1, &Theme::default());
let joined: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
joined.contains("init"),
"expected the seed 'init' subject, got {:?}",
joined
);
assert!(
!joined.contains('…'),
"must not pre-emptively truncate with ellipsis: {:?}",
joined
);
}
use gwm::trust::TrustMode;
fn app_with_config(toml_body: &str) -> (tempfile::TempDir, App) {
let (dir, _repo) = init_repo();
std::fs::write(dir.path().join(".gwm.toml"), toml_body).unwrap();
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
(dir, app)
}
fn toml_basic_string(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', "\\\\").replace('"', "\\\"")
}
#[test]
fn tui_gate_passes_when_no_gwm_toml_present() {
let (_dir, app) = make_app();
assert!(
matches!(app.check_trust_for_bootstrap(), Ok(None)),
"no .gwm.toml → gate must clear"
);
}
#[test]
fn tui_gate_passes_on_empty_bootstrap_surface() {
let (_dir, app) = app_with_config(
r#"[worktree]
base = "/tmp/never-used"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
);
assert!(
matches!(app.check_trust_for_bootstrap(), Ok(None)),
"empty surface → gate must clear"
);
}
#[test]
fn tui_gate_refuses_untrusted_config_in_prompt_mode() {
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior_ledger = std::env::var("GWM_TRUST_LEDGER").ok();
let prior_allow = std::env::var("GWM_ALLOW_BOOTSTRAP").ok();
unsafe {
std::env::set_var("GWM_TRUST_LEDGER", &ledger);
std::env::remove_var("GWM_ALLOW_BOOTSTRAP");
}
let (_dir, app) = app_with_config(
r#"[[bootstrap.command]]
name = "x"
run = "true"
"#,
);
match app.check_trust_for_bootstrap() {
Ok(Some(msg)) => {
assert!(
msg.contains("not in trust ledger"),
"refuse message must point at the gate (got: {})",
msg
);
assert!(
msg.contains("--allow-bootstrap") || msg.contains("GWM_ALLOW_BOOTSTRAP"),
"refuse message must surface the bypass options (got: {})",
msg
);
}
other => panic!("expected refuse, got {:?}", other),
}
unsafe {
match prior_ledger {
Some(v) => std::env::set_var("GWM_TRUST_LEDGER", v),
None => std::env::remove_var("GWM_TRUST_LEDGER"),
}
match prior_allow {
Some(v) => std::env::set_var("GWM_ALLOW_BOOTSTRAP", v),
None => std::env::remove_var("GWM_ALLOW_BOOTSTRAP"),
}
}
}
#[test]
fn tui_gate_clears_under_allow_mode() {
let (_dir, app) = app_with_config(
r#"[[bootstrap.command]]
name = "x"
run = "true"
"#,
);
let app = app.with_trust_mode(TrustMode::Allow);
assert!(
matches!(app.check_trust_for_bootstrap(), Ok(None)),
"Allow mode → gate must clear regardless of ledger state"
);
}
#[test]
fn tui_gate_refuses_under_deny_mode_even_with_safe_config() {
let (_dir, app) = app_with_config(
r#"[[bootstrap.command]]
name = "x"
run = "true"
"#,
);
let app = app.with_trust_mode(TrustMode::Deny);
let outcome = app.check_trust_for_bootstrap();
match outcome {
Ok(Some(msg)) => assert!(
msg.contains("--deny-bootstrap"),
"deny refuse must name the flag (got: {})",
msg
),
other => panic!("expected deny refuse, got {:?}", other),
}
}
#[test]
fn tui_submit_create_aborts_on_untrusted_config() {
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
let base_dir = tempfile::TempDir::new().unwrap();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior_ledger = std::env::var("GWM_TRUST_LEDGER").ok();
let prior_allow = std::env::var("GWM_ALLOW_BOOTSTRAP").ok();
unsafe {
std::env::set_var("GWM_TRUST_LEDGER", &ledger);
std::env::remove_var("GWM_ALLOW_BOOTSTRAP");
}
let body = format!(
r#"[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
[[bootstrap.command]]
name = "echo"
run = "echo would-have-run"
"#,
base = toml_basic_string(base_dir.path()),
);
let (_dir, mut app) = app_with_config(&body);
let feat_idx = app
.branch_types
.iter()
.position(|t| t.name == "feat")
.expect("`feat` is in BRANCH_TYPES defaults");
app.create_form.type_index = feat_idx;
app.create_form.issue = "42".into();
app.create_form.desc = "untrusted-creates".into();
app.submit_create().expect("submit_create must surface a soft refusal");
assert!(
app.status.contains("not in trust ledger"),
"status must reflect the gate refusal (got: {})",
app.status
);
let would_have_been = base_dir.path().join("feat-42-untrusted-creates");
assert!(
!would_have_been.exists(),
"worktree dir MUST NOT be created when the gate refuses (got: {})",
would_have_been.display()
);
unsafe {
match prior_ledger {
Some(v) => std::env::set_var("GWM_TRUST_LEDGER", v),
None => std::env::remove_var("GWM_TRUST_LEDGER"),
}
match prior_allow {
Some(v) => std::env::set_var("GWM_ALLOW_BOOTSTRAP", v),
None => std::env::remove_var("GWM_ALLOW_BOOTSTRAP"),
}
}
let _ = BRANCH_TYPES; }
fn fill_create_form(app: &mut App, issue: &str, desc: &str) {
let feat_idx = app
.branch_types
.iter()
.position(|t| t.name == "feat")
.expect("`feat` is in branch types");
app.create_form.type_index = feat_idx;
app.create_form.issue = issue.into();
app.create_form.desc = desc.into();
}
#[test]
fn submit_create_starts_async_create_and_keeps_create_modal_open() {
use gwm::tui::state::async_task::TaskKind;
let base_dir = tempfile::TempDir::new().unwrap();
let body = format!(
r#"[worktree]
base = "{base}"
path_pattern = "{{type}}-{{issue}}-{{desc}}"
branch_pattern = "{{type}}/#{{issue}}-{{desc}}"
"#,
base = toml_basic_string(base_dir.path()),
);
let (_dir, mut app) = app_with_config(&body);
app.view = View::Create;
fill_create_form(&mut app, "276", "async-create");
app
.submit_create()
.expect("submit_create must only enqueue async create");
assert_eq!(app.view, View::Create, "the modal stays open while create runs");
assert!(
app.tasks.is_loading(TaskKind::CreateWorktree),
"create must claim an async loading slot"
);
assert_eq!(app.status, TaskKind::CreateWorktree.loading_label());
for _ in 0..300 {
if app.drain_task_results() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!app.tasks.is_loading(TaskKind::CreateWorktree),
"background create should drain before temp dirs are dropped"
);
}
#[test]
fn drain_applies_async_create_result_and_flips_to_report_view() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{CreateWorktreeResult, TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::CreateWorktree).unwrap();
app.view = View::Create;
app
.task_result_sender()
.send(TaskMsg::CreateWorktree(
generation,
Ok(CreateWorktreeResult {
branch: "feat/#276-async-create".into(),
created: PathBuf::from("/tmp/gwm-created"),
report: BootstrapReport {
steps: vec![StepResult::ok("post_create hook")],
},
}),
))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "a live create result must be applied");
assert_eq!(app.view, View::Report);
assert!(app.report.is_some(), "create report is shown in the Report view");
assert!(
app.status.contains("created feat/#276-async-create @ /tmp/gwm-created"),
"status reports the created branch and path: {:?}",
app.status
);
assert!(!app.tasks.is_loading(TaskKind::CreateWorktree));
}
#[test]
fn drain_create_failure_stays_in_create_and_reports_status() {
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::CreateWorktree).unwrap();
app.view = View::Create;
app
.task_result_sender()
.send(TaskMsg::CreateWorktree(generation, Err("branch already exists".into())))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "a create failure still clears the live slot");
assert_eq!(app.view, View::Create);
assert_eq!(app.status, "create failed: branch already exists");
assert!(!app.tasks.is_loading(TaskKind::CreateWorktree));
}
#[test]
fn drain_drops_a_superseded_create_result() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{CreateWorktreeResult, TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::CreateWorktree).unwrap();
app.tasks.invalidate(TaskKind::CreateWorktree);
app.view = View::Create;
app.status = "untouched".into();
app
.task_result_sender()
.send(TaskMsg::CreateWorktree(
stale,
Ok(CreateWorktreeResult {
branch: "feat/#276-stale".into(),
created: PathBuf::from("/tmp/stale"),
report: BootstrapReport {
steps: vec![StepResult::ok("stale")],
},
}),
))
.unwrap();
app.drain_task_results();
assert_eq!(app.view, View::Create);
assert_eq!(app.status, "untouched");
assert!(app.report.is_none());
}
#[test]
fn tui_bootstrap_selected_aborts_on_untrusted_config() {
let ledger_dir = tempfile::TempDir::new().unwrap();
let ledger = ledger_dir.path().join("trust.toml");
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior_ledger = std::env::var("GWM_TRUST_LEDGER").ok();
let prior_allow = std::env::var("GWM_ALLOW_BOOTSTRAP").ok();
unsafe {
std::env::set_var("GWM_TRUST_LEDGER", &ledger);
std::env::remove_var("GWM_ALLOW_BOOTSTRAP");
}
let (_dir, mut app) = app_with_config(
r#"[[bootstrap.command]]
name = "echo"
run = "echo trapped"
"#,
);
app.worktrees = vec![worktree_fixture("dummy")];
app.list_state.select(Some(0));
app.bootstrap_selected();
assert!(
app.status.contains("not in trust ledger"),
"status must reflect the gate refusal (got: {})",
app.status
);
unsafe {
match prior_ledger {
Some(v) => std::env::set_var("GWM_TRUST_LEDGER", v),
None => std::env::remove_var("GWM_TRUST_LEDGER"),
}
match prior_allow {
Some(v) => std::env::set_var("GWM_ALLOW_BOOTSTRAP", v),
None => std::env::remove_var("GWM_ALLOW_BOOTSTRAP"),
}
}
}
#[test]
fn bootstrap_selected_with_no_selection_reports_and_does_not_load() {
let (_dir, mut app) = make_app();
app.worktrees.clear();
app.bootstrap_selected();
assert_eq!(app.status, "nothing selected");
assert!(!app.is_task_loading(), "no worktree selected → no task claimed");
}
#[test]
fn drain_applies_async_bootstrap_report_and_flips_to_report_view() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-x");
let generation = app
.tasks
.request(TaskKind::Bootstrap)
.expect("a cold bootstrap slot must hand out a generation");
assert!(app.is_task_loading(), "request must mark the app as loading");
let report = BootstrapReport {
steps: vec![StepResult::ok("copy .env"), StepResult::ok("post_create hook")],
};
app
.task_result_sender()
.send(TaskMsg::Bootstrap(generation, Ok(report)))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "a live bootstrap result must be applied");
assert_eq!(app.view, View::Report, "completion flips to the Report view");
assert!(app.report.is_some(), "the report is stored for the Report view");
assert_eq!(app.status, "bootstrap ok");
assert!(!app.is_task_loading(), "completion clears the in-flight slot");
}
#[test]
fn drain_bootstrap_report_with_a_failed_step_says_had_failures() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-x");
let generation = app.tasks.request(TaskKind::Bootstrap).unwrap();
let report = BootstrapReport {
steps: vec![
StepResult::ok("copy .env"),
StepResult::failed("post_create hook", "exit 1"),
],
};
app
.task_result_sender()
.send(TaskMsg::Bootstrap(generation, Ok(report)))
.unwrap();
app.drain_task_results();
assert_eq!(app.view, View::Report, "a partial failure still shows the report");
assert_eq!(app.status, "bootstrap had failures");
}
#[test]
fn a_late_bootstrap_result_is_dropped_and_keeps_the_list_view() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-x");
let stale = app.tasks.request(TaskKind::Bootstrap).unwrap();
app.tasks.invalidate(TaskKind::Bootstrap);
app
.task_result_sender()
.send(TaskMsg::Bootstrap(
stale,
Ok(BootstrapReport {
steps: vec![StepResult::ok("stale")],
}),
))
.unwrap();
app.drain_task_results();
assert_eq!(app.view, View::List, "a dropped late result must not flip the view");
assert!(app.report.is_none(), "a dropped late result must not store a report");
}
#[test]
fn drain_bootstrap_error_reports_status_and_does_not_flip_to_report() {
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-x");
let generation = app.tasks.request(TaskKind::Bootstrap).unwrap();
app
.task_result_sender()
.send(TaskMsg::Bootstrap(generation, Err("disk full".into())))
.unwrap();
app.drain_task_results();
assert_eq!(
app.view,
View::List,
"a failed bootstrap stays on the list, no Report to show"
);
assert!(app.report.is_none());
assert_eq!(app.status, "bootstrap error: disk full");
}
#[test]
fn drain_bootstrap_report_flips_to_report_even_from_another_view() {
use gwm::bootstrap::{BootstrapReport, StepResult};
use gwm::tui::{TaskKind, TaskMsg};
let (_dir, _repo, mut app) = make_app_on_branch("feat/#42-x");
let generation = app.tasks.request(TaskKind::Bootstrap).unwrap();
app.view = View::Create;
app
.task_result_sender()
.send(TaskMsg::Bootstrap(
generation,
Ok(BootstrapReport {
steps: vec![StepResult::ok("copy .env")],
}),
))
.unwrap();
app.drain_task_results();
assert_eq!(
app.view,
View::Report,
"a live bootstrap result takes the screen even mid-create"
);
}
#[test]
fn link_target_is_canonical_across_cli_and_tui() {
let from_cli: gwm::cli::LinkTarget = gwm::cli::LinkTarget::Issue;
let from_tui: gwm::tui::LinkTarget = from_cli;
assert_eq!(from_tui, gwm::tui::LinkTarget::Issue);
let from_tui: gwm::tui::LinkTarget = gwm::tui::LinkTarget::Pr;
let from_cli: gwm::cli::LinkTarget = from_tui;
assert_eq!(from_cli, gwm::cli::LinkTarget::Pr);
}
#[test]
fn fresh_app_confirm_modal_focuses_cancel() {
use gwm::tui::ConfirmButton;
let (_dir, app) = make_app();
assert_eq!(app.confirm.focused_button(), ConfirmButton::Cancel);
}
#[test]
fn fresh_app_spinner_starts_at_first_frame() {
use gwm::tui::state::spinner::DOT_FRAMES;
let (_dir, app) = make_app();
assert_eq!(app.spinner.glyph(DOT_FRAMES), DOT_FRAMES[0]);
}
#[test]
fn help_scroll_clamps_between_zero_and_max() {
let (_dir, mut app) = make_app();
app.enter_help();
assert_eq!(app.view, View::Help);
assert_eq!(app.help_scroll, 0, "a freshly opened help starts at the top");
app.help_max_scroll = 3;
app.help_scroll_down();
app.help_scroll_down();
assert_eq!(app.help_scroll, 2);
app.help_scroll_down();
app.help_scroll_down();
assert_eq!(app.help_scroll, 3, "scroll-down clamps at the published max");
app.help_scroll_up();
assert_eq!(app.help_scroll, 2);
for _ in 0..10 {
app.help_scroll_up();
}
assert_eq!(app.help_scroll, 0, "scroll-up clamps at the top");
app.help_scroll = 2;
app.enter_help();
assert_eq!(app.help_scroll, 0, "(re)opening help returns to the top");
}
#[test]
fn help_horizontal_scroll_clamps_between_zero_and_max() {
let (_dir, mut app) = make_app();
app.enter_help();
assert_eq!(app.help_x_scroll, 0);
app.help_max_x_scroll = 2;
app.help_scroll_right();
assert_eq!(app.help_x_scroll, 1);
app.help_scroll_right();
app.help_scroll_right();
assert_eq!(app.help_x_scroll, 2, "scroll-right clamps at the published max");
app.help_scroll_left();
assert_eq!(app.help_x_scroll, 1);
app.help_scroll_left();
app.help_scroll_left();
assert_eq!(app.help_x_scroll, 0, "scroll-left clamps at the left edge");
app.help_x_scroll = 2;
app.enter_help();
assert_eq!(app.help_x_scroll, 0, "(re)opening help returns to the left edge");
}
#[test]
fn create_key_typing_appends_to_the_focused_text_field() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Desc;
for c in "my-feat".chars() {
assert!(matches!(
app.handle_create_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
CreateKey::Handled
));
}
assert_eq!(app.create_form.desc, "my-feat");
}
#[test]
fn create_key_rejects_issue_letters_with_status_feedback() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app.enter_create();
assert_eq!(app.create_form.field, Field::Issue);
assert!(matches!(
app.handle_create_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
CreateKey::Handled
));
assert!(app.create_form.issue.is_empty());
assert!(
app.create_form.desc.is_empty(),
"non-digit Issue input must stay on Issue and never append to Desc"
);
assert!(
app.status.contains("digits"),
"status should explain the digits-only Issue field, got {:?}",
app.status
);
app.handle_create_key(KeyEvent::new(KeyCode::Char('7'), KeyModifiers::NONE));
assert_eq!(app.create_form.issue, "7");
assert!(app.create_form.desc.is_empty());
}
#[test]
fn create_key_hl_cycles_the_type_only_when_type_is_focused() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Type;
app.handle_create_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert_eq!(app.create_form.type_index, 1, "l advances the type");
app.handle_create_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert_eq!(app.create_form.type_index, 0, "h steps back");
app.create_form.field = Field::Desc;
app.handle_create_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
app.handle_create_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert_eq!(app.create_form.desc, "hl");
assert_eq!(app.create_form.type_index, 0, "type stays put while editing desc");
}
#[test]
fn create_key_enter_advances_then_submits_on_desc() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app.enter_create();
app.create_form.field = Field::Issue;
assert!(matches!(
app.handle_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
CreateKey::Handled
));
assert_eq!(
app.create_form.field,
Field::Desc,
"Enter off the desc field advances focus"
);
assert!(matches!(
app.handle_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
CreateKey::Submit
));
}
#[test]
fn create_key_esc_requests_cancel() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app.enter_create();
assert!(matches!(
app.handle_create_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
CreateKey::Cancel
));
}
#[test]
fn drain_applies_async_refresh_result() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
assert!(app.is_task_loading(), "request must mark the app as loading");
let fresh = vec![worktree_fixture("alpha"), worktree_fixture("beta")];
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(generation, Ok(fresh)))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "drain must report it applied a result");
assert_eq!(app.worktrees.len(), 2, "the fresh list replaces the old one");
assert!(!app.is_task_loading(), "no task should be inflight after draining");
assert!(
app.status.contains("refreshed"),
"status reports the refresh outcome: {:?}",
app.status
);
}
#[test]
fn maybe_refresh_sidebar_is_a_noop_when_the_cache_is_current() {
use gwm::tui::state::async_task::TaskKind;
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let w = app.selected().expect("a worktree is selected").clone();
let mode = app.sidebar.mode;
app.sidebar.cache = Some(((w.path.clone(), mode), SidebarSections::default()));
app.maybe_refresh_sidebar();
assert!(
!app.tasks.is_loading(TaskKind::Sidebar),
"a cache current for the selection must not spawn a rebuild"
);
}
#[test]
fn maybe_refresh_sidebar_coalesces_a_held_navigation_onto_one_worker() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let gen = app
.tasks
.request(TaskKind::Sidebar)
.expect("cold slot claims a generation");
app.sidebar.cache = None;
app.maybe_refresh_sidebar();
app.maybe_refresh_sidebar();
let path = app.selected().unwrap().path.clone();
let mode = app.sidebar.mode;
app
.task_result_sender()
.send(TaskMsg::Sidebar(gen, path.clone(), mode, SidebarSections::default()))
.unwrap();
assert!(
app.drain_task_results(),
"the original worker's result must still apply — the ticks coalesced, they did not re-request"
);
assert!(
matches!(&app.sidebar.cache, Some(((p, _), _)) if *p == path),
"the coalesced worker's payload lands in the cache"
);
}
#[test]
fn drain_applies_a_sidebar_rebuild_and_clears_the_slot() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
use gwm::tui::state::sidebar::SidebarMode;
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let gen = app.tasks.request(TaskKind::Sidebar).unwrap();
let path = PathBuf::from("/tmp/gwm-test/alpha");
let mode = SidebarMode::Commits;
app
.task_result_sender()
.send(TaskMsg::Sidebar(gen, path.clone(), mode, SidebarSections::default()))
.unwrap();
assert!(app.drain_task_results(), "drain reports it applied the sidebar payload");
assert!(
matches!(&app.sidebar.cache, Some(((p, m), _)) if *p == path && *m == mode),
"the payload is stored under the (path, mode) it was built for"
);
assert!(
!app.tasks.is_loading(TaskKind::Sidebar),
"the slot is cleared once the result applies"
);
}
#[test]
fn drain_drops_a_superseded_sidebar_rebuild() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
use gwm::tui::state::sidebar::SidebarMode;
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::Sidebar).unwrap();
app.tasks.invalidate(TaskKind::Sidebar); app.sidebar.cache = None;
app
.task_result_sender()
.send(TaskMsg::Sidebar(
stale,
PathBuf::from("/tmp/gwm-test/ghost"),
SidebarMode::Commits,
SidebarSections::default(),
))
.unwrap();
app.drain_task_results();
assert!(
app.sidebar.cache.is_none(),
"a superseded sidebar payload must be dropped, not stored (the #138 guard)"
);
}
#[test]
fn refresh_invalidates_an_inflight_sidebar_rebuild() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::Sidebar).unwrap();
let path = app.selected().unwrap().path.clone();
let mode = app.sidebar.mode;
app.refresh().unwrap();
app
.task_result_sender()
.send(TaskMsg::Sidebar(stale, path, mode, SidebarSections::default()))
.unwrap();
app.drain_task_results();
assert!(
app.sidebar.cache.is_none(),
"refresh() must drop a pre-mutation sidebar payload so it can't clobber the fresh preview"
);
}
#[test]
fn drain_async_refresh_invalidates_an_inflight_sidebar_rebuild() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
use gwm::tui::SidebarSections;
let (_dir, mut app) = make_app();
let stale_sidebar = app.tasks.request(TaskKind::Sidebar).unwrap();
let path = app.selected().unwrap().path.clone();
let mode = app.sidebar.mode;
let refresh_gen = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(
refresh_gen,
Ok(vec![worktree_fixture("alpha")]),
))
.unwrap();
assert!(app.drain_task_results(), "the async refresh applies");
app
.task_result_sender()
.send(TaskMsg::Sidebar(stale_sidebar, path, mode, SidebarSections::default()))
.unwrap();
app.drain_task_results();
assert!(
app.sidebar.cache.is_none(),
"the async refresh must drop the pre-refresh sidebar payload, not store it as current"
);
}
#[test]
fn maybe_refresh_sidebar_skips_a_hidden_sidebar() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.sidebar.open = false;
app.sidebar.cache = None;
app.maybe_refresh_sidebar();
assert!(
!app.tasks.is_loading(TaskKind::Sidebar),
"a hidden sidebar must do no preview work"
);
}
#[cfg(unix)]
#[test]
fn worktree_refresh_fetches_issue_and_pr_status_for_every_linked_worktree() {
use gwm::github::BranchLink;
use gwm::tui::{TaskKind, TaskMsg};
use std::os::unix::fs::PermissionsExt;
let (dir, repo, mut app) = make_app_on_branch("feat/#42-selected");
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
let fake_gh = dir.path().join("fake-gh-refresh-all");
std::fs::write(
&fake_gh,
"#!/bin/sh\n\
kind=\"$1\"\n\
number=\"$3\"\n\
if [ \"$kind\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"issue %s\",\"state\":\"CLOSED\",\"url\":\"https://example.test/issues/%s\",\"labels\":[],\"updatedAt\":\"2026-06-09T00:00:00Z\"}' \"$number\" \"$number\" \"$number\"\n\
elif [ \"$kind\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"pr %s\",\"state\":\"MERGED\",\"isDraft\":false,\"url\":\"https://example.test/pull/%s\",\"updatedAt\":\"2026-06-09T00:00:00Z\",\"statusCheckRollup\":[]}' \"$number\" \"$number\" \"$number\"\n\
else\n\
exit 2\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&fake_gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake_gh, perms).unwrap();
let linked = |name: &str, branch: &str, issue: u64, pr: u64| {
let mut w = worktree_fixture(name);
w.branch = Some(branch.into());
w.link = BranchLink {
issue: Some(issue),
pr: Some(pr),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::Explicit,
pr_source: LinkSource::Explicit,
};
w
};
for (branch, issue, pr) in [("feat/#42-selected", 42, 61), ("feat/#283-other", 283, 286)] {
gwm::github::link_issue(&repo, branch, issue).unwrap();
gwm::github::link_pr(&repo, branch, pr).unwrap();
}
let fresh = vec![
linked("selected", "feat/#42-selected", 42, 61),
linked("other", "feat/#283-other", 283, 286),
];
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &fake_gh);
}
let generation = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(generation, Ok(fresh)))
.unwrap();
assert!(app.drain_task_results(), "the worktree refresh result must apply");
assert!(
app.tasks.is_loading(TaskKind::GithubIssue(42)),
"worktree refresh must fetch the selected issue"
);
assert!(
app.tasks.is_loading(TaskKind::GithubPr(61)),
"worktree refresh must fetch the selected PR"
);
assert!(
app.tasks.is_loading(TaskKind::GithubIssue(283)),
"worktree refresh must fetch issues from non-selected rows too"
);
assert!(
app.tasks.is_loading(TaskKind::GithubPr(286)),
"worktree refresh must fetch PRs from non-selected rows too"
);
for _ in 0..50 {
if !app.tasks.is_loading(TaskKind::GithubIssue(42))
&& !app.tasks.is_loading(TaskKind::GithubPr(61))
&& !app.tasks.is_loading(TaskKind::GithubIssue(283))
&& !app.tasks.is_loading(TaskKind::GithubPr(286))
{
break;
}
std::thread::sleep(Duration::from_millis(10));
app.drain_task_results();
}
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
let theme = Theme::default();
for (idx, issue, pr) in [(0, 42, 61), (1, 283, 286)] {
let cells = marker_cells(&gwm::tui::table_marker(&app.worktrees[idx], &theme));
assert_eq!(
cells[0].1,
Some(gwm::tui::issue_badge_color(IssueState::Closed, &theme)),
"issue #{issue} marker should reflect the fetched closed state"
);
assert_eq!(
cells[2].1,
Some(gwm::tui::pr_badge_color(PrState::Merged, &theme)),
"PR #{pr} marker should reflect the fetched merged state"
);
}
for (branch, issue_title, pr_title) in [
("feat/#42-selected", "issue 42", "pr 61"),
("feat/#283-other", "issue 283", "pr 286"),
] {
let link = gwm::github::read_link(&repo, branch).unwrap();
assert_eq!(
link.issue_title.as_deref(),
Some(issue_title),
"issue title should persist on branch {branch}"
);
assert_eq!(
link.issue_state,
Some(IssueState::Closed),
"issue state should persist on branch {branch}"
);
assert_eq!(
link.pr_title.as_deref(),
Some(pr_title),
"PR title should persist on branch {branch}"
);
assert_eq!(
link.pr_state,
Some(PrState::Merged),
"PR state should persist on branch {branch}"
);
}
}
#[cfg(unix)]
#[test]
fn app_startup_fetches_issue_and_pr_status_for_linked_worktrees() {
use gwm::tui::TaskKind;
use std::os::unix::fs::PermissionsExt;
let (dir, repo) = init_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/#42-startup", &head, false).unwrap();
}
repo.set_head("refs/heads/feat/#42-startup").unwrap();
repo.remote("origin", "https://github.com/kbrdn1/gwm-cli.git").unwrap();
gwm::github::link_pr(&repo, "feat/#42-startup", 61).unwrap();
let fake_gh = dir.path().join("fake-gh-startup-refresh");
std::fs::write(
&fake_gh,
"#!/bin/sh\n\
kind=\"$1\"\n\
number=\"$3\"\n\
if [ \"$kind\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"issue %s\",\"state\":\"OPEN\",\"url\":\"https://example.test/issues/%s\",\"labels\":[],\"updatedAt\":\"2026-06-09T00:00:00Z\"}' \"$number\" \"$number\" \"$number\"\n\
elif [ \"$kind\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"pr %s\",\"state\":\"OPEN\",\"isDraft\":false,\"url\":\"https://example.test/pull/%s\",\"updatedAt\":\"2026-06-09T00:00:00Z\",\"statusCheckRollup\":[]}' \"$number\" \"$number\" \"$number\"\n\
else\n\
exit 2\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&fake_gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake_gh, perms).unwrap();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &fake_gh);
}
let app = App::new_at_layered(Some(dir.path()), None).unwrap();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert!(
app.tasks.is_loading(TaskKind::GithubIssue(42)),
"startup should fetch the linked issue immediately"
);
assert!(
app.tasks.is_loading(TaskKind::GithubPr(61)),
"startup should fetch the linked PR immediately"
);
}
#[cfg(unix)]
#[test]
fn github_refresh_fetches_only_the_current_link_without_relisting_worktrees() {
use gwm::github::BranchLink;
use gwm::tui::TaskKind;
use std::os::unix::fs::PermissionsExt;
let (dir, _repo, mut app) = make_app_on_branch("feat/#42-selected");
let fake_gh = dir.path().join("fake-gh-current-only");
std::fs::write(
&fake_gh,
"#!/bin/sh\n\
kind=\"$1\"\n\
number=\"$3\"\n\
if [ \"$kind\" = \"issue\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"issue %s\",\"state\":\"OPEN\",\"url\":\"https://example.test/issues/%s\",\"labels\":[],\"updatedAt\":\"2026-06-09T00:00:00Z\"}' \"$number\" \"$number\" \"$number\"\n\
elif [ \"$kind\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n\
printf '{\"number\":%s,\"title\":\"pr %s\",\"state\":\"OPEN\",\"isDraft\":false,\"url\":\"https://example.test/pull/%s\",\"updatedAt\":\"2026-06-09T00:00:00Z\",\"statusCheckRollup\":[]}' \"$number\" \"$number\" \"$number\"\n\
else\n\
exit 2\n\
fi\n",
)
.unwrap();
let mut perms = std::fs::metadata(&fake_gh).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake_gh, perms).unwrap();
let mut selected = worktree_fixture("selected");
selected.branch = Some("feat/#42-selected".into());
selected.link = BranchLink {
issue: Some(42),
pr: Some(61),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::Explicit,
pr_source: LinkSource::Explicit,
};
let mut other = worktree_fixture("other");
other.branch = Some("feat/#283-other".into());
other.link = BranchLink {
issue: Some(283),
pr: Some(286),
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::Explicit,
pr_source: LinkSource::Explicit,
};
app.github.link = selected.link.clone();
app.github.link_slug = Some("kbrdn1/gwm-cli".into());
app.worktrees = vec![selected, other];
app.list_state.select(Some(0));
let names_before: Vec<String> = app.worktrees.iter().map(|w| w.name.clone()).collect();
let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prior = std::env::var("GWM_GH").ok();
unsafe {
std::env::set_var("GWM_GH", &fake_gh);
}
app.refresh_github_status();
unsafe {
match prior {
Some(v) => std::env::set_var("GWM_GH", v),
None => std::env::remove_var("GWM_GH"),
}
}
assert!(
!app.tasks.is_loading(TaskKind::RefreshWorktrees),
"GitHub refresh must not relist worktrees"
);
assert_eq!(
app.worktrees.iter().map(|w| w.name.clone()).collect::<Vec<_>>(),
names_before,
"GitHub refresh must leave the worktree list untouched"
);
assert!(app.tasks.is_loading(TaskKind::GithubIssue(42)));
assert!(app.tasks.is_loading(TaskKind::GithubPr(61)));
assert!(
!app.tasks.is_loading(TaskKind::GithubIssue(283)),
"GitHub refresh must not fetch a non-selected row's issue"
);
assert!(
!app.tasks.is_loading(TaskKind::GithubPr(286)),
"GitHub refresh must not fetch a non-selected row's PR"
);
}
#[test]
fn drain_drops_async_refresh_invalidated_mid_flight() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let before = app.worktrees.len();
let stale = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app.tasks.invalidate(TaskKind::RefreshWorktrees);
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(stale, Ok(vec![worktree_fixture("ghost")])))
.unwrap();
app.drain_task_results();
assert_eq!(
app.worktrees.len(),
before,
"a refresh invalidated mid-flight must be dropped, not applied"
);
}
#[test]
fn drain_async_refresh_failure_surfaces_status_without_touching_the_list() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let before = app.worktrees.len();
let generation = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(generation, Err("boom".into())))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "a failure still counts as a drained result");
assert_eq!(app.worktrees.len(), before, "a failed refresh leaves the list intact");
assert!(!app.is_task_loading(), "the slot clears even on failure");
assert!(
app.status.contains("boom"),
"the error reaches the status bar: {:?}",
app.status
);
}
fn sync_report_integrated(behind: usize) -> gwm::sync::SyncReport {
gwm::sync::SyncReport {
branch: "feat/#258-x".into(),
upstream: "origin/main".into(),
strategy: gwm::sync::SyncStrategy::Rebase,
ahead_before: 0,
behind_before: behind,
action: gwm::sync::SyncAction::Integrated,
}
}
#[test]
fn drain_applies_sync_report_and_reports_the_outcome() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::Sync).unwrap();
assert!(app.is_task_loading(), "request must mark the app as loading");
app
.task_result_sender()
.send(TaskMsg::Sync(generation, "alpha".into(), Ok(sync_report_integrated(3))))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "drain must report it applied a result");
assert!(!app.is_task_loading(), "the sync slot clears after draining");
assert!(
app.status.contains("rebased 3 commits"),
"status reports the sync outcome, not the refresh line: {:?}",
app.status
);
}
#[test]
fn drain_sync_failure_surfaces_on_the_status_bar() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::Sync).unwrap();
app
.task_result_sender()
.send(TaskMsg::Sync(
generation,
"alpha".into(),
Err("branch 'feat/#258-x' has no upstream configured".into()),
))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "a failure still counts as a drained result");
assert!(!app.is_task_loading(), "the slot clears even on failure");
assert!(
app.status.contains("sync failed") && app.status.contains("no upstream"),
"the sync error reaches the status bar: {:?}",
app.status
);
}
#[test]
fn drain_drops_a_superseded_sync_result() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::Sync).unwrap();
app.tasks.invalidate(TaskKind::Sync);
app.status = "untouched".into();
app
.task_result_sender()
.send(TaskMsg::Sync(stale, "alpha".into(), Ok(sync_report_integrated(2))))
.unwrap();
app.drain_task_results();
assert_eq!(
app.status, "untouched",
"a sync result invalidated mid-flight must be dropped, not reported"
);
}
#[test]
fn drain_delete_worktree_success_returns_to_list_and_reports_removed_target() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::DeleteWorktree).unwrap();
app.view = View::Confirm;
app.delete_failure = Some("old failure".into());
app
.task_result_sender()
.send(TaskMsg::DeleteWorktree(
generation,
"alpha".into(),
"/tmp/alpha".into(),
Ok(()),
))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "delete result should be applied");
assert!(!app.is_delete_worktree_loading(), "delete slot clears after success");
assert_eq!(app.view, View::List);
assert!(app.delete_failure.is_none(), "old failure is cleared after success");
assert!(
app.status.contains("removed alpha") && app.status.contains("/tmp/alpha"),
"status reports the removed target: {:?}",
app.status
);
}
#[test]
fn drain_delete_worktree_failure_stays_in_confirm_and_records_failure() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::DeleteWorktree).unwrap();
app.view = View::Confirm;
app
.task_result_sender()
.send(TaskMsg::DeleteWorktree(
generation,
"alpha".into(),
"/tmp/alpha".into(),
Err("permission denied".into()),
))
.unwrap();
let applied = app.drain_task_results();
assert!(applied, "delete failure should still be applied");
assert!(!app.is_delete_worktree_loading(), "delete slot clears after failure");
assert_eq!(app.view, View::Confirm);
assert_eq!(app.delete_failure.as_deref(), Some("permission denied"));
assert!(
app.status.contains("delete failed") && app.status.contains("permission denied"),
"status reports the delete failure: {:?}",
app.status
);
}
#[test]
fn drain_drops_a_superseded_delete_worktree_result() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::DeleteWorktree).unwrap();
app.tasks.invalidate(TaskKind::DeleteWorktree);
app.view = View::Confirm;
app.status = "untouched".into();
app
.task_result_sender()
.send(TaskMsg::DeleteWorktree(
stale,
"alpha".into(),
"/tmp/alpha".into(),
Ok(()),
))
.unwrap();
app.drain_task_results();
assert_eq!(app.view, View::Confirm);
assert_eq!(app.status, "untouched");
}
#[test]
fn request_sync_coalesces_onto_an_inflight_run() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::Sync).unwrap();
app.request_sync(); assert!(app.is_task_loading());
assert!(
app.tasks.complete(TaskKind::Sync, generation),
"the original sync is still authoritative after a coalesced press"
);
}
#[test]
fn request_sync_with_no_selection_reports_and_does_not_claim_a_slot() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.list_state.select(None);
app.request_sync();
assert!(
!app.tasks.is_loading(TaskKind::Sync),
"with nothing selected, request_sync must not claim a sync slot"
);
assert!(
app.status.contains("no worktree selected"),
"request_sync reports the missing selection: {:?}",
app.status
);
}
#[test]
fn request_refresh_coalesces_onto_an_inflight_run() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let generation = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app.request_refresh(); assert!(app.is_task_loading());
assert!(
app.tasks.complete(TaskKind::RefreshWorktrees, generation),
"the original run is still the authoritative one after a coalesced press"
);
}
#[test]
fn auto_refresh_triggers_after_default_interval_without_resetting_selection() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.worktrees = vec![worktree_fixture("alpha"), worktree_fixture("beta")];
app.list_state.select(Some(1));
let start = Instant::now();
app.last_auto_refresh_at = start;
assert!(
!app.maybe_auto_refresh(start + Duration::from_secs(59)),
"default interval is 60s, so 59s must not refresh"
);
assert_eq!(app.list_state.selected(), Some(1), "selection stays put before refresh");
assert!(
app.maybe_auto_refresh(start + Duration::from_secs(60)),
"60s default interval should trigger a worktree refresh"
);
assert!(
app.tasks.is_loading(TaskKind::RefreshWorktrees),
"auto-refresh uses the async refresh task"
);
assert_eq!(
app.list_state.selected(),
Some(1),
"requesting auto-refresh must not reset the user's selection"
);
}
#[test]
fn auto_refresh_advances_timer_when_refresh_is_already_inflight() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let start = Instant::now();
app.last_auto_refresh_at = start;
let generation = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
let elapsed = start + Duration::from_secs(60);
assert!(
!app.maybe_auto_refresh(elapsed),
"an in-flight refresh coalesces instead of spawning a second worker"
);
assert_eq!(
app.last_auto_refresh_at, elapsed,
"coalescing still advances the timer to avoid an immediate follow-up refresh"
);
assert!(
app.tasks.complete(TaskKind::RefreshWorktrees, generation),
"the original refresh remains authoritative"
);
assert!(
!app.maybe_auto_refresh(elapsed + Duration::from_secs(1)),
"the next event-loop tick should not immediately start another auto-refresh"
);
}
#[test]
fn auto_refresh_zero_is_disabled() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.config.tui.auto_refresh_secs = 0;
let start = Instant::now();
app.last_auto_refresh_at = start;
assert!(
!app.maybe_auto_refresh(start + Duration::from_secs(3600)),
"auto_refresh_secs = 0 disables periodic refresh"
);
assert!(
!app.tasks.is_loading(TaskKind::RefreshWorktrees),
"disabled auto-refresh must not claim a refresh task"
);
}
#[test]
fn quit_waits_while_a_sync_task_is_in_flight() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::Sync).unwrap();
assert!(!app.can_quit_now());
}
#[test]
fn quit_waits_while_a_create_worktree_task_is_in_flight() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::CreateWorktree).unwrap();
assert!(!app.can_quit_now());
app.defer_quit_for_mutating_task();
assert_eq!(app.status, "finishing creating worktree before quit…");
}
#[test]
fn quit_waits_while_a_bootstrap_task_is_in_flight() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::Bootstrap).unwrap();
assert!(!app.can_quit_now());
}
#[test]
fn quit_waits_while_a_delete_worktree_task_is_in_flight() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::DeleteWorktree).unwrap();
assert!(!app.can_quit_now());
app.defer_quit_for_mutating_task();
assert_eq!(app.status, "finishing deleting worktree before quit…");
}
#[test]
fn quit_does_not_wait_for_read_only_tasks() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app.tasks.request(TaskKind::GithubIssue(42)).unwrap();
app.tasks.request(TaskKind::GithubPr(7)).unwrap();
assert!(app.can_quit_now());
}
#[test]
fn quit_waiting_status_explains_the_deferred_exit() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
app.should_quit = true;
app.tasks.request(TaskKind::Bootstrap).unwrap();
assert!(!app.can_quit_now());
app.defer_quit_for_mutating_task();
assert_eq!(app.status, "finishing bootstrapping before quit…");
}
#[test]
fn sync_refresh_invalidates_an_inflight_async_refresh() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let (_dir, mut app) = make_app();
let stale = app.tasks.request(TaskKind::RefreshWorktrees).unwrap();
app.refresh().unwrap();
let authoritative = app.worktrees.len();
app
.task_result_sender()
.send(TaskMsg::RefreshWorktrees(stale, Ok(vec![worktree_fixture("ghost")])))
.unwrap();
app.drain_task_results();
assert_eq!(
app.worktrees.len(),
authoritative,
"the stale pre-mutation snapshot must not replace the sync-refreshed list"
);
assert!(
!app.worktrees.iter().any(|w| w.name == "ghost"),
"the dropped result's payload must never reach the list"
);
}
#[test]
fn activate_choice_setting_persists_project_layer_and_applies_live() {
use gwm::config::{Config, SidebarPosition};
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Tui;
app.config_panel.selected = 0; assert_eq!(app.config.tui.sidebar_position, SidebarPosition::Right);
app.activate_selected_setting();
assert_eq!(
app.config.tui.sidebar_position,
SidebarPosition::Left,
"live config updated"
);
assert_eq!(
app.sidebar.position,
SidebarPosition::Left,
"live sidebar position re-seeded"
);
let written = std::fs::read_to_string(dir.path().join(".gwm.toml")).unwrap();
assert!(
written.contains("sidebar_position"),
"edit persisted to .gwm.toml: {written}"
);
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(
cfg.tui.sidebar_position,
SidebarPosition::Left,
"edit round-trips through a fresh layered load"
);
}
#[test]
fn committing_numeric_input_persists_the_typed_value() {
use gwm::config::Config;
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Tui;
app.config_panel.selected = 2;
app.activate_selected_setting();
assert!(
app.config_panel.editing.is_some(),
"Enter on a Uint field opens the input"
);
app.config_panel.editing = Some("5".into());
app.commit_settings_edit();
assert!(app.config_panel.editing.is_none(), "commit closes the input");
assert_eq!(app.config.tui.confirm_countdown_secs, 5, "live config updated");
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.confirm_countdown_secs, 5, "typed value persisted");
}
#[test]
fn committing_auto_refresh_secs_persists_the_typed_value() {
use gwm::config::Config;
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Tui;
app.config_panel.selected = 3;
app.activate_selected_setting();
assert!(
app.config_panel.editing.is_some(),
"Enter on auto refresh opens the numeric input"
);
app.config_panel.editing = Some("0".into());
app.commit_settings_edit();
assert_eq!(app.config.tui.auto_refresh_secs, 0, "live config updated");
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.auto_refresh_secs, 0, "typed value persisted");
}
#[test]
fn committing_text_input_persists_a_worktree_pattern() {
use gwm::config::Config;
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Worktree;
app.config_panel.selected = 0;
app.activate_selected_setting();
assert!(
app.config_panel.editing.is_some(),
"Enter on a Text field opens the input"
);
app.config_panel.editing = Some("{home}/custom-wt/{repo}".into());
app.commit_settings_edit();
assert_eq!(
app.config.worktree.base, "{home}/custom-wt/{repo}",
"live config updated"
);
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.worktree.base, "{home}/custom-wt/{repo}", "text value persisted");
}
#[test]
fn committing_numeric_looking_text_persists_as_a_string() {
use gwm::config::Config;
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::Worktree;
app.config_panel.selected = 0;
app.activate_selected_setting();
app.config_panel.editing = Some("404".into());
app.commit_settings_edit();
assert_eq!(app.config.worktree.base, "404", "live config keeps the text value");
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.worktree.base, "404", "numeric-looking text persisted as a string");
}
#[test]
fn command_logs_transcript_is_newest_first_and_empty_when_blank() {
use gwm::command_log::{CommandLogEntry, CommandStatus};
use std::time::Duration;
let (_dir, mut app) = make_app();
assert!(app.command_logs_transcript().is_empty());
app.command_logs.entries = vec![
CommandLogEntry {
command: "first cmd".into(),
duration: Duration::from_millis(10),
status: CommandStatus::Exited(Some(0)),
output: "ok".into(),
},
CommandLogEntry {
command: "second cmd".into(),
duration: Duration::from_millis(20),
status: CommandStatus::Exited(Some(2)),
output: "boom".into(),
},
];
let t = app.command_logs_transcript();
assert!(
t.contains("$ first cmd") && t.contains("$ second cmd"),
"both argv present: {t}"
);
assert!(
t.find("second cmd").unwrap() < t.find("first cmd").unwrap(),
"newest entry must come first: {t}"
);
assert!(t.contains("→ exit 2"), "non-zero exit is recorded: {t}");
assert!(
t.contains("boom") && t.contains("ok"),
"captured output is included: {t}"
);
}
#[test]
fn activate_is_a_noop_on_the_read_only_all_tab() {
use gwm::tui::SettingsTab;
let (dir, mut app) = make_app();
app.enter_config_panel();
app.config_panel.tab = SettingsTab::All;
app.activate_selected_setting();
assert!(
!dir.path().join(".gwm.toml").exists(),
"the read-only All tab must not write anything"
);
}
#[test]
fn enter_edit_worktree_prefills_create_form_from_branch() {
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("fix/#42-broken-thing".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit);
assert_eq!(app.create_form.issue, "42");
assert_eq!(app.create_form.desc, "broken-thing");
assert_eq!(
app.branch_types[app.create_form.type_index].name, "fix",
"the type selector must point at the parsed branch type"
);
assert_eq!(app.edit_original_branch.as_deref(), Some("fix/#42-broken-thing"));
assert!(
app.edit_original_path.is_some(),
"the original path is captured for git worktree move"
);
}
#[test]
fn enter_edit_worktree_rejects_unparseable_branch() {
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("main".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::List, "unparseable branch must not open the modal");
assert!(app.edit_original_branch.is_none());
}
#[test]
fn cancel_edit_worktree_resets_state() {
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("feat/#1-x".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::Edit);
app.cancel_edit_worktree();
assert_eq!(app.view, View::List);
assert!(app.edit_original_branch.is_none());
assert!(app.edit_original_path.is_none());
}
#[test]
fn request_push_refuses_while_another_mutation_runs() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("feat/#1-x".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.tasks.request(TaskKind::Sync);
app.request_push();
assert!(
!app.tasks.is_loading(TaskKind::Push),
"push must not start while a sync is in flight"
);
assert!(
app.status.contains("before pushing"),
"status must explain the block: {}",
app.status
);
}
#[test]
fn request_pull_refuses_while_another_mutation_runs() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("feat/#1-x".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.tasks.request(TaskKind::Bootstrap);
app.request_pull();
assert!(
!app.tasks.is_loading(TaskKind::Pull),
"pull must not start while a bootstrap is in flight"
);
assert!(
app.status.contains("before pulling"),
"status must explain the block: {}",
app.status
);
}
#[test]
fn enter_edit_worktree_refuses_unconfigured_branch_type() {
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("zzz/#7-thing".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.enter_edit_worktree();
assert_eq!(app.view, View::List, "unconfigured type must not open the modal");
assert!(app.edit_original_branch.is_none());
assert!(
app.status.contains("not configured"),
"status must explain: {}",
app.status
);
}
#[test]
fn request_sync_refuses_while_another_mutation_runs() {
use gwm::tui::state::async_task::TaskKind;
let (_dir, mut app) = make_app();
let mut wt = worktree_fixture("foo");
wt.branch = Some("feat/#1-x".into());
app.worktrees = vec![wt];
app.list_state.select(Some(0));
app.tasks.request(TaskKind::Pull);
app.request_sync();
assert!(
!app.tasks.is_loading(TaskKind::Sync),
"sync must not start while a pull is in flight"
);
assert!(
app.status.contains("before syncing"),
"status must explain: {}",
app.status
);
}
#[test]
fn create_modal_honours_a_rebound_submit_key() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::ModalAction;
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app
.modal_keymap
.apply_override(
ModalAction::CreateSubmit,
vec![KeyStroke::new(KeyCode::F(2), KeyModifiers::empty())],
)
.unwrap();
app.handle_create_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.handle_create_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.create_form.field, Field::Desc);
assert_eq!(
app.handle_create_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)),
CreateKey::Submit
);
assert_eq!(
app.handle_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
CreateKey::Handled
);
}
#[test]
fn create_modal_type_cycle_keys_stay_literal_on_text_fields() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::CreateKey;
let (_dir, mut app) = make_app();
app.handle_create_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.handle_create_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(app.create_form.field, Field::Desc);
let before = app.create_form.type_index;
assert_eq!(
app.handle_create_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)),
CreateKey::Handled
);
assert_eq!(
app.create_form.type_index, before,
"type must not cycle while typing a description"
);
assert!(
app.create_form.desc.contains('l'),
"`l` must reach the description buffer as literal text"
);
}
#[test]
fn resolve_modal_reflects_a_confirm_rebind() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::{KeyContext, ModalAction};
let (_dir, mut app) = make_app();
app
.modal_keymap
.apply_override(
ModalAction::ConfirmConfirm,
vec![KeyStroke::new(KeyCode::Char('o'), KeyModifiers::empty())],
)
.unwrap();
assert_eq!(
app.resolve_modal(
KeyContext::Confirm,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
),
Some(ModalAction::ConfirmConfirm)
);
assert_eq!(
app.resolve_modal(
KeyContext::Confirm,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)
),
None
);
}
#[test]
fn link_input_number_context_advertises_its_own_hints() {
use crossterm::event::{KeyCode, KeyModifiers};
use gwm::tui::keymap::{KeyStroke, Keymap};
use gwm::tui::modal_keymap::{ModalAction, ModalKeymap};
use gwm::tui::HintContext;
let mut modal = ModalKeymap::defaults();
modal
.apply_override(
ModalAction::LinkInputSubmit,
vec![KeyStroke::new(KeyCode::Char('x'), KeyModifiers::empty())],
)
.unwrap();
let resolved = HintContext::LinkInputNumber.resolve(&Keymap::defaults(), &modal);
assert!(
resolved.iter().any(|(k, l)| l == "submit" && k == "x"),
"submit hint must show the rebound key, got {resolved:?}"
);
assert!(
!resolved.iter().any(|(_, l)| l == "kind" || l == "move"),
"the input-number stage must not advertise choose-target hints: {resolved:?}"
);
}
#[test]
fn hint_context_switches_to_link_input_number_while_typing() {
use gwm::tui::HintContext;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app.enter_link_prompt();
assert_eq!(app.hint_context(), HintContext::LinkPrompt, "choose-target stage");
app.link_prompt_choose(LinkTarget::Issue);
assert_eq!(app.link_prompt_stage(), LinkPromptStage::InputNumber);
assert_eq!(app.hint_context(), HintContext::LinkInputNumber, "number-input stage");
}
#[test]
fn link_modal_binding_on_fetch_key_wins_over_fetch_fallback() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::ModalAction;
use gwm::tui::LinkPromptKey;
let (_dir, _repo, mut app) = make_app_on_branch("random-branch");
app
.modal_keymap
.apply_override(
ModalAction::LinkInputSubmit,
vec![KeyStroke::new(KeyCode::Char('F'), KeyModifiers::empty())],
)
.unwrap();
app.enter_link_prompt();
app.link_prompt_choose(LinkTarget::Issue); assert!(
matches!(
app.handle_link_prompt_key(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE)),
LinkPromptKey::Submit
),
"a contextual binding on the fetch key must win over the fetch fallback"
);
}
fn app_with_gwm_toml(toml: &str) -> (tempfile::TempDir, App) {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gwm.toml"), toml).unwrap();
let app = App::new_at_layered(Some(repo.path()), None).unwrap();
(repo, app)
}
#[test]
fn exec_picker_refuses_to_open_with_no_profiles() {
let (repo, _) = init_repo();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_exec_picker();
assert_eq!(app.view, View::List, "no profiles ⇒ no transition");
assert!(
app.status.contains("exec.profiles"),
"the status explains why nothing opened: {}",
app.status
);
}
#[test]
fn exec_picker_opens_and_lists_profiles_sorted() {
let (_repo, mut app) = app_with_gwm_toml(
"[exec.profiles.test]\ncommand = [\"cargo\", \"test\"]\n\n[exec.profiles.build]\ncommand = [\"cargo\", \"build\"]\n",
);
app.enter_exec_picker();
assert_eq!(app.view, View::ExecPicker);
assert_eq!(app.exec_picker.profiles(), &["build".to_string(), "test".to_string()]);
assert_eq!(app.exec_picker.selected_profile(), Some("build"));
}
#[test]
fn exec_picker_navigation_moves_the_highlight() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::ExecPickerKey;
let (_repo, mut app) =
app_with_gwm_toml("[exec.profiles.a]\ncommand = [\"true\"]\n\n[exec.profiles.b]\ncommand = [\"true\"]\n");
app.enter_exec_picker();
let down = app.handle_exec_picker_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(down, ExecPickerKey::Handled);
assert_eq!(app.exec_picker.selected_profile(), Some("b"));
app.handle_exec_picker_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.exec_picker.selected_profile(), Some("a"));
}
#[test]
fn exec_picker_enter_submits_and_esc_cancels() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gwm::tui::ExecPickerKey;
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.a]\ncommand = [\"true\"]\n");
app.enter_exec_picker();
assert_eq!(
app.handle_exec_picker_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
ExecPickerKey::Submit
);
assert_eq!(
app.handle_exec_picker_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
ExecPickerKey::Cancel
);
}
#[test]
fn exec_picker_resolves_the_highlighted_profile_to_its_argv() {
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.build]\ncommand = [\"cargo\", \"build\", \"--release\"]\n");
app.enter_exec_picker();
let expected_cwd = app.selected().unwrap().path.clone();
let (argv, cwd) = app.exec_picker_resolve().expect("a valid profile resolves");
assert_eq!(
argv,
vec!["cargo".to_string(), "build".to_string(), "--release".to_string()],
"argv is the frozen command array verbatim (no shell)"
);
assert_eq!(cwd, expected_cwd, "cwd is the selected worktree's path");
}
#[test]
fn exec_picker_close_returns_to_the_list() {
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.a]\ncommand = [\"true\"]\n");
app.enter_exec_picker();
assert_eq!(app.view, View::ExecPicker);
app.close_exec_picker();
assert_eq!(app.view, View::List);
}
#[test]
fn clean_overlay_scans_gitignored_artifacts() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "target/\n").unwrap();
std::fs::create_dir(repo.path().join("target")).unwrap();
std::fs::write(repo.path().join("target").join("blob"), vec![0u8; 2048]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert_eq!(app.view, View::CleanReport);
let reclaim = app.clean_overlay.reclaim().expect("the worktree was scanned");
assert!(
reclaim.artifacts.iter().any(|a| a.rel == "target"),
"the git-ignored target/ is counted: {:?}",
reclaim.artifacts
);
assert!(app.clean_overlay.total_bytes() >= 2048);
}
#[test]
fn clean_overlay_gate_skips_non_gitignored_artifacts() {
let (repo, _) = init_repo();
std::fs::create_dir(repo.path().join("target")).unwrap();
std::fs::write(repo.path().join("target").join("blob"), vec![0u8; 1024]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
let reclaim = app.clean_overlay.reclaim().expect("scanned");
assert!(reclaim.artifacts.is_empty(), "a non-ignored target/ is never counted");
assert!(
app.clean_overlay.skipped().contains(&"target".to_string()),
"and is reported as skipped: {:?}",
app.clean_overlay.skipped()
);
assert_eq!(app.clean_overlay.total_bytes(), 0);
}
#[test]
fn clean_overlay_delete_reclaims_only_the_gated_dir() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "build/\n").unwrap();
std::fs::create_dir(repo.path().join("build")).unwrap();
std::fs::write(repo.path().join("build").join("out"), vec![0u8; 512]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert!(repo.path().join("build").exists());
app.clean_overlay_delete();
assert!(!repo.path().join("build").exists(), "the build dir was reclaimed");
assert_eq!(app.view, View::List, "and the overlay closed");
assert!(
app.status.contains("reclaimed"),
"status reports the reclaim: {}",
app.status
);
}
#[test]
fn clean_confirm_arms_then_is_ready_after_the_countdown() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "dist/\n").unwrap();
std::fs::create_dir(repo.path().join("dist")).unwrap();
std::fs::write(repo.path().join("dist").join("x"), vec![0u8; 100]).unwrap();
std::fs::write(repo.path().join(".gwm.toml"), "[tui]\nconfirm_countdown_secs = 3\n").unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
let t0 = Instant::now();
assert_eq!(app.clean_confirm_press(t0), ConfirmKeyAction::Armed);
assert!(app.clean_overlay.confirm.is_armed());
assert_eq!(
app.tick_clean_countdown(t0 + Duration::from_secs(1)),
CountdownTickOutcome::Pending
);
assert_eq!(
app.tick_clean_countdown(t0 + Duration::from_secs(3)),
CountdownTickOutcome::ReadyToFire
);
}
#[test]
fn clean_confirm_with_zero_countdown_fires_immediately() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "node_modules/\n").unwrap();
std::fs::create_dir(repo.path().join("node_modules")).unwrap();
std::fs::write(repo.path().join("node_modules").join("y"), vec![0u8; 64]).unwrap();
std::fs::write(repo.path().join(".gwm.toml"), "[tui]\nconfirm_countdown_secs = 0\n").unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert_eq!(app.clean_confirm_press(Instant::now()), ConfirmKeyAction::FireNow);
}
#[test]
fn clean_confirm_is_a_noop_when_nothing_to_reclaim() {
let (repo, _) = init_repo(); let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert!(app.clean_overlay.is_empty_scan());
assert_eq!(app.clean_confirm_press(Instant::now()), ConfirmKeyAction::Disarmed);
assert!(app.status.contains("nothing to reclaim"), "status: {}", app.status);
}
#[test]
fn clean_overlay_profile_picker_rescans_per_profile() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "cache/\nout/\n").unwrap();
std::fs::create_dir(repo.path().join("cache")).unwrap();
std::fs::write(repo.path().join("cache").join("c"), vec![0u8; 100]).unwrap();
std::fs::create_dir(repo.path().join("out")).unwrap();
std::fs::write(repo.path().join("out").join("o"), vec![0u8; 200]).unwrap();
std::fs::write(
repo.path().join(".gwm.toml"),
"[clean.profiles.a]\ndirs = [\"cache\"]\n\n[clean.profiles.b]\ndirs = [\"out\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert_eq!(app.clean_overlay.selected_profile(), None);
app.clean_overlay_next();
assert_eq!(app.clean_overlay.selected_profile(), Some("a"));
let a = app.clean_overlay.reclaim().unwrap();
assert!(a.artifacts.iter().any(|x| x.rel == "cache"));
assert!(!a.artifacts.iter().any(|x| x.rel == "out"));
app.clean_overlay_next();
assert_eq!(app.clean_overlay.selected_profile(), Some("b"));
let b = app.clean_overlay.reclaim().unwrap();
assert!(b.artifacts.iter().any(|x| x.rel == "out"));
assert!(!b.artifacts.iter().any(|x| x.rel == "cache"));
}
#[test]
fn clean_overlay_close_returns_to_the_list() {
let (repo, _) = init_repo();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert_eq!(app.view, View::CleanReport);
app.close_clean_overlay();
assert_eq!(app.view, View::List);
}
#[test]
fn clean_overlay_opens_on_the_no_profile_default_choice() {
let (repo, _) = init_repo();
std::fs::write(
repo.path().join(".gwm.toml"),
"[clean.profiles.aggressive]\ndirs = [\"target\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert_eq!(
app.clean_overlay.selected_profile(),
None,
"opens on the no-profile choice"
);
assert_eq!(app.clean_overlay.choice_labels().first().copied(), Some("(default)"));
assert!(
app.clean_overlay.has_profiles(),
"a named profile makes the picker worth showing"
);
}
#[test]
fn clean_overlay_default_choice_uses_builtins_without_a_default_profile() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "target/\ncoverage/\n").unwrap();
std::fs::create_dir(repo.path().join("target")).unwrap();
std::fs::write(repo.path().join("target").join("t"), vec![0u8; 128]).unwrap();
std::fs::create_dir(repo.path().join("coverage")).unwrap();
std::fs::write(repo.path().join("coverage").join("c"), vec![0u8; 256]).unwrap();
std::fs::write(
repo.path().join(".gwm.toml"),
"[clean.profiles.coverage]\ndirs = [\"coverage\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
let r = app.clean_overlay.reclaim().unwrap();
assert!(
r.artifacts.iter().any(|a| a.rel == "target"),
"built-in target/ is reachable"
);
assert!(
!r.artifacts.iter().any(|a| a.rel == "coverage"),
"the named profile is not the default"
);
}
#[test]
fn clean_overlay_delete_revalidates_the_gate_just_before_removing() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "target/\n").unwrap();
std::fs::create_dir(repo.path().join("target")).unwrap();
std::fs::write(repo.path().join("target").join("blob"), vec![0u8; 256]).unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert!(app
.clean_overlay
.reclaim()
.unwrap()
.artifacts
.iter()
.any(|a| a.rel == "target"));
std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["add", "-f", "target/blob"])
.status()
.unwrap();
app.clean_overlay_delete();
assert!(
repo.path().join("target").exists(),
"a directory that became tracked after the scan must not be reclaimed"
);
assert_eq!(app.view, View::List, "the overlay still closes");
}
#[test]
fn exec_picker_runs_in_the_open_time_worktree_and_config_after_a_drift() {
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.a]\ncommand = [\"echo\", \"hi\"]\n");
let opened = app.selected().unwrap().path.clone();
app.enter_exec_picker();
app.worktrees = vec![worktree_fixture("other")];
app.config.exec.profiles.clear();
let (argv, cwd) = app
.exec_picker_resolve()
.expect("resolves against the captured cfg + cwd");
assert_eq!(argv, vec!["echo".to_string(), "hi".to_string()]);
assert_eq!(cwd, opened, "runs in the open-time worktree, not the drifted selection");
}
#[test]
fn clean_overlay_deletes_the_open_time_target_and_config_after_a_drift() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "build/\n").unwrap();
std::fs::create_dir(repo.path().join("build")).unwrap();
std::fs::write(repo.path().join("build").join("o"), vec![0u8; 64]).unwrap();
std::fs::write(
repo.path().join(".gwm.toml"),
"[clean.profiles.x]\ndirs = [\"build\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
app.clean_overlay_next(); assert_eq!(app.clean_overlay.selected_profile(), Some("x"));
app.worktrees = vec![worktree_fixture("other")];
app.config.clean.profiles.clear();
app.clean_overlay_delete();
assert!(
!repo.path().join("build").exists(),
"reclaimed via the captured target + config despite the drift"
);
}
#[test]
fn exec_picker_pins_a_worktree_relative_program_to_the_target() {
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.run]\ncommand = [\"./run.sh\", \"--ci\"]\n");
let wt = app.selected().unwrap().path.clone();
app.enter_exec_picker();
let (argv, _cwd) = app.exec_picker_resolve().expect("resolves");
assert_eq!(
argv[0],
gwm::exec::resolve_program(&wt, "./run.sh").to_string_lossy(),
"the relative executable is pinned to the worktree"
);
assert!(std::path::Path::new(&argv[0]).is_absolute(), "and is absolute");
assert_eq!(argv[1], "--ci", "the args are passed through unchanged");
}
#[test]
fn exec_picker_leaves_a_bare_command_for_path_lookup() {
let (_repo, mut app) = app_with_gwm_toml("[exec.profiles.t]\ncommand = [\"cargo\", \"test\"]\n");
app.enter_exec_picker();
let (argv, _cwd) = app.exec_picker_resolve().expect("resolves");
assert_eq!(argv, vec!["cargo".to_string(), "test".to_string()]);
}
#[test]
fn clean_countdown_is_pinned_to_the_open_time_config() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gwm.toml"), "[tui]\nconfirm_countdown_secs = 3\n").unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
app.config.tui.confirm_countdown_secs = 0;
assert_eq!(
app.clean_countdown_total(),
Duration::from_secs(3),
"the safety delay captured at open survives a live config swap"
);
}
#[test]
fn destructive_overlay_open_flags_exec_and_clean_views() {
let (_repo, mut app) = make_app();
assert!(
!app.destructive_overlay_open(),
"list view is not a destructive overlay"
);
app.view = View::ExecPicker;
assert!(app.destructive_overlay_open());
app.view = View::CleanReport;
assert!(app.destructive_overlay_open());
app.view = View::Confirm;
assert!(!app.destructive_overlay_open());
}
#[test]
fn clean_overlay_noop_profile_move_keeps_the_countdown_armed() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "build/\n").unwrap();
std::fs::create_dir(repo.path().join("build")).unwrap();
std::fs::write(repo.path().join("build").join("o"), vec![0u8; 64]).unwrap();
std::fs::write(repo.path().join(".gwm.toml"), "[tui]\nconfirm_countdown_secs = 3\n").unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert!(!app.clean_overlay.has_profiles(), "only the (default) choice exists");
assert_eq!(app.clean_confirm_press(Instant::now()), ConfirmKeyAction::Armed);
app.clean_overlay_next();
assert!(app.clean_overlay.confirm.is_armed(), "a no-op move must not disarm");
app.clean_overlay_prev();
assert!(
app.clean_overlay.confirm.is_armed(),
"prev no-op must not disarm either"
);
}
#[test]
fn clean_overlay_real_profile_change_disarms_the_countdown() {
let (repo, _) = init_repo();
std::fs::write(repo.path().join(".gitignore"), "a/\nb/\n").unwrap();
for d in ["a", "b"] {
std::fs::create_dir(repo.path().join(d)).unwrap();
std::fs::write(repo.path().join(d).join("x"), vec![0u8; 64]).unwrap();
}
std::fs::write(
repo.path().join(".gwm.toml"),
"[tui]\nconfirm_countdown_secs = 3\n\n[clean.profiles.pa]\ndirs = [\"a\"]\n\n[clean.profiles.pb]\ndirs = [\"b\"]\n",
)
.unwrap();
let mut app = App::new_at_layered(Some(repo.path()), None).unwrap();
app.enter_clean_overlay();
assert!(app.clean_overlay.has_profiles());
app.clean_overlay_next(); assert_eq!(app.clean_overlay.selected_profile(), Some("pa"));
assert_eq!(app.clean_confirm_press(Instant::now()), ConfirmKeyAction::Armed);
app.clean_overlay_next(); assert!(
!app.clean_overlay.confirm.is_armed(),
"changing the target re-requires confirmation"
);
}