mod common;
use common::init_repo;
use gwm::github::{BranchLink, CiState, IssueState, PrState};
use gwm::tui::commit_graph::{render_pipe_set, test_row, Pipe, PipeKind};
use gwm::tui::state::sidebar::SidebarMode;
use gwm::tui::theme::Theme;
use gwm::tui::{
branch_name_color, branch_status_color, build_sidebar_sections, footer_line, format_status, freshness_color,
github_status_lines, header_line, help_label_style, issue_badge_color, palette_name_style, pr_badge_color,
table_marker, working_tree_status_line, worktree_name_style, worktree_path_style, App,
};
use gwm::worktree::{BranchStatus, WorktreeInfo};
use ratatui::style::{Color, Modifier};
use ratatui::text::Line;
use std::path::PathBuf;
use std::time::Duration;
fn audit_theme() -> Theme {
Theme {
focus: Color::Rgb(1, 1, 1),
accent: Color::Rgb(2, 2, 2),
branch: Color::Rgb(3, 3, 3),
clean: Color::Rgb(4, 4, 4),
dirty: Color::Rgb(5, 5, 5),
main: Color::Rgb(6, 6, 6),
locked: Color::Rgb(7, 7, 7),
prunable: Color::Rgb(8, 8, 8),
muted: Color::Rgb(9, 9, 9),
selection_bg: Color::Rgb(10, 10, 10),
name: Color::Rgb(11, 11, 11),
path: Color::Rgb(12, 12, 12),
staged: Color::Rgb(13, 13, 13),
modified: Color::Rgb(14, 14, 14),
untracked: Color::Rgb(15, 15, 15),
}
}
fn fg_containing(line: &Line<'_>, needle: &str) -> Option<Color> {
line
.spans
.iter()
.find(|s| s.content.contains(needle))
.and_then(|s| s.style.fg)
}
fn dirty_status() -> BranchStatus {
BranchStatus {
is_dirty: true,
has_upstream: true,
ahead: 0,
behind: 0,
unknown: false,
}
}
#[test]
fn branch_name_color_resolves_every_state_through_theme_roles() {
let t = audit_theme();
let synced = BranchStatus {
has_upstream: true,
..BranchStatus::default()
};
assert_eq!(branch_name_color(&synced, &t), t.branch, "synced → branch");
assert_eq!(branch_name_color(&dirty_status(), &t), t.prunable, "dirty → prunable");
let ahead = BranchStatus {
has_upstream: true,
ahead: 2,
..BranchStatus::default()
};
assert_eq!(branch_name_color(&ahead, &t), t.dirty, "ahead → dirty");
let unpublished = BranchStatus::default(); assert_eq!(branch_name_color(&unpublished, &t), t.locked, "no upstream → locked");
let unknown = BranchStatus {
unknown: true,
..BranchStatus::default()
};
assert_eq!(branch_name_color(&unknown, &t), t.muted, "unknown → muted");
}
fn status_color_cases() -> Vec<(&'static str, BranchStatus)> {
vec![
(
"unknown",
BranchStatus {
unknown: true,
..BranchStatus::default()
},
),
("dirty", dirty_status()),
(
"behind-only",
BranchStatus {
has_upstream: true,
behind: 3,
..BranchStatus::default()
},
),
(
"ahead-only",
BranchStatus {
has_upstream: true,
ahead: 2,
..BranchStatus::default()
},
),
(
"synced",
BranchStatus {
has_upstream: true,
..BranchStatus::default()
},
),
("unpublished-clean", BranchStatus::default()),
]
}
#[test]
fn branch_status_color_resolves_worst_status_through_theme_roles() {
let t = audit_theme();
let role = |label: &str| match label {
"unknown" => t.muted,
"dirty" | "behind-only" => t.dirty,
"ahead-only" => t.accent,
_ => t.clean,
};
for (label, s) in status_color_cases() {
assert_eq!(branch_status_color(&s, &t), role(label), "{label} → expected role");
}
}
#[test]
fn format_status_colour_routes_through_branch_status_color() {
let t = audit_theme();
for (label, s) in status_color_cases() {
assert_eq!(
format_status(&s, 16, &t).1,
branch_status_color(&s, &t),
"{label}: format_status colour must equal branch_status_color"
);
}
}
#[test]
fn freshness_color_resolves_through_theme_roles() {
let t = audit_theme();
assert_eq!(freshness_color(Duration::from_secs(0), &t), t.clean, "fresh → clean");
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 14), &t),
t.dirty,
"ageing → dirty"
);
assert_eq!(
freshness_color(Duration::from_secs(86_400 * 90), &t),
t.muted,
"stale → muted"
);
}
#[test]
fn pr_badge_color_resolves_through_theme_roles() {
let t = audit_theme();
assert_eq!(pr_badge_color(PrState::Open, &t), t.clean, "open → clean");
assert_eq!(pr_badge_color(PrState::Draft, &t), t.muted, "draft → muted");
assert_eq!(pr_badge_color(PrState::Merged, &t), t.locked, "merged → locked");
assert_eq!(pr_badge_color(PrState::Closed, &t), t.prunable, "closed → prunable");
}
#[test]
fn issue_badge_color_resolves_through_theme_roles() {
let t = audit_theme();
assert_eq!(issue_badge_color(IssueState::Open, &t), t.clean, "open → clean");
assert_eq!(issue_badge_color(IssueState::Closed, &t), t.locked, "closed → locked");
}
#[test]
fn table_marker_resolves_through_theme_roles() {
let t = audit_theme();
let mut main = base_worktree("main");
main.is_main = true;
assert_eq!(
table_marker(&main, &t).spans[0].style.fg,
Some(t.main),
"main marker → main role"
);
let mut issue_only = base_worktree("issue");
issue_only.link = BranchLink {
issue: Some(7),
..BranchLink::empty()
};
let line = table_marker(&issue_only, &t);
assert_eq!(line.spans[2].content.as_ref(), "-", "empty pr slot → dash");
assert_eq!(line.spans[0].style.fg, Some(t.clean), "issue dot → clean role");
assert_eq!(line.spans[1].style.fg, Some(t.muted), "separator → muted role");
assert_eq!(line.spans[2].style.fg, Some(t.name), "empty pr dash → name role");
let mut closed_issue = issue_only.clone();
closed_issue.issue_state = Some(IssueState::Closed);
let line = table_marker(&closed_issue, &t);
assert_eq!(
line.spans[0].style.fg,
Some(issue_badge_color(IssueState::Closed, &t)),
"closed issue dot → issue_badge_color closed role"
);
let mut pr_only = base_worktree("pr");
pr_only.link = BranchLink {
pr: Some(8),
..BranchLink::empty()
};
let line = table_marker(&pr_only, &t);
assert_eq!(line.spans[0].content.as_ref(), "-", "empty issue slot → dash");
assert_eq!(line.spans[0].style.fg, Some(t.name), "empty issue dash → name role");
assert_eq!(line.spans[2].style.fg, Some(t.locked), "pr dot → locked role");
let mut closed_pr = pr_only.clone();
closed_pr.pr_state = Some(PrState::Closed);
let line = table_marker(&closed_pr, &t);
assert_eq!(
line.spans[2].style.fg,
Some(pr_badge_color(PrState::Closed, &t)),
"closed pr dot → pr_badge_color closed role"
);
let unlinked = base_worktree("plain");
let line = table_marker(&unlinked, &t);
assert_eq!(line.spans[0].content.as_ref(), "-", "empty issue slot → dash");
assert_eq!(line.spans[0].style.fg, Some(t.name), "empty issue dash → name role");
assert_eq!(line.spans[2].content.as_ref(), "-", "empty pr slot → dash");
assert_eq!(line.spans[2].style.fg, Some(t.name), "empty pr dash → name role");
}
#[test]
fn working_tree_status_line_resolves_change_categories_through_dedicated_roles() {
let t = audit_theme();
assert_ne!(t.untracked, t.modified, "fixture: untracked must differ from modified");
assert_ne!(t.prunable, t.modified, "fixture: prunable must differ from modified");
assert_ne!(t.untracked, t.prunable, "fixture: untracked must differ from prunable");
let added = working_tree_status_line("A staged.rs", &t);
assert_eq!(
added.spans[0].style.fg,
Some(t.untracked),
"added code → untracked role"
);
assert_eq!(
fg_containing(&added, "staged.rs"),
Some(t.untracked),
"added name → untracked role"
);
let untracked = working_tree_status_line("?? new.rs", &t);
assert_eq!(
fg_containing(&untracked, "new.rs"),
Some(t.untracked),
"untracked name → untracked role"
);
let modified = working_tree_status_line(" M tracked.rs", &t);
assert_eq!(
fg_containing(&modified, "tracked.rs"),
Some(t.modified),
"modified name → modified role"
);
let deleted = working_tree_status_line(" D gone.rs", &t);
assert_eq!(
deleted.spans[0].style.fg,
Some(t.prunable),
"deleted code → prunable role"
);
assert_eq!(
fg_containing(&deleted, "gone.rs"),
Some(t.prunable),
"deleted name → prunable role"
);
}
#[test]
fn working_tree_status_categories_default_to_green_yellow_red() {
let d = Theme::default();
assert_eq!(
working_tree_status_line("A s.rs", &d).spans[0].style.fg,
Some(Color::Green),
"default added → Green"
);
assert_eq!(
fg_containing(&working_tree_status_line(" M t.rs", &d), "t.rs"),
Some(Color::Yellow),
"default modified → Yellow"
);
assert_eq!(
working_tree_status_line(" D g.rs", &d).spans[0].style.fg,
Some(Color::Red),
"default deleted → Red"
);
assert_eq!(
fg_containing(&working_tree_status_line("?? n.rs", &d), "n.rs"),
Some(Color::Green),
"default untracked → Green"
);
}
#[test]
fn issue_summary_line_closed_badge_agrees_with_the_header_dot() {
let t = audit_theme();
let status = gwm::github::IssueStatus {
number: 42,
title: "done".into(),
state: IssueState::Closed,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let line = gwm::tui::issue_summary_line(
42,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Loaded(status),
80,
&t,
);
assert_eq!(
fg_containing(&line, "closed"),
Some(t.locked),
"closed issue badge → locked, matching issue_badge_color"
);
assert_eq!(
fg_containing(&line, "closed"),
Some(issue_badge_color(IssueState::Closed, &t)),
"summary line and the header dot must use the same role for closed issues"
);
}
#[test]
fn pr_summary_line_merged_badge_routes_through_pr_badge_color() {
let t = audit_theme();
let status = gwm::github::PrStatus {
number: 42,
title: "shipped".into(),
state: PrState::Merged,
url: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
checks: vec![],
updated_at: String::new(),
};
let line = gwm::tui::pr_summary_line(
42,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Loaded(status),
80,
&t,
None,
);
assert_eq!(
fg_containing(&line, "merged"),
Some(t.locked),
"merged PR badge → locked, matching pr_badge_color"
);
assert_eq!(
fg_containing(&line, "merged"),
Some(pr_badge_color(PrState::Merged, &t)),
"summary line and the header dot must use the same role for merged PRs"
);
}
#[test]
fn name_and_path_styles_resolve_through_theme_roles() {
let t = audit_theme();
let name = worktree_name_style(&t);
assert_eq!(name.fg, Some(t.name), "worktree name → name role");
assert!(
name.add_modifier.contains(Modifier::BOLD),
"the worktree name stays bold so it anchors each row"
);
assert_eq!(worktree_path_style(&t).fg, Some(t.path), "table path cell → path role");
}
#[test]
fn name_and_path_styles_default_to_legacy_white_and_gray() {
let d = Theme::default();
assert_eq!(worktree_name_style(&d).fg, Some(Color::White), "default name → White");
assert_eq!(worktree_path_style(&d).fg, Some(Color::Gray), "default path → Gray");
}
#[test]
fn palette_and_help_labels_resolve_through_name_role() {
let t = audit_theme();
assert_eq!(
palette_name_style(&t).fg,
Some(t.name),
"palette command name → name role"
);
assert_eq!(help_label_style(&t).fg, Some(t.name), "help entry label → name role");
}
#[test]
fn palette_and_help_labels_default_to_legacy_white() {
let d = Theme::default();
assert_eq!(
palette_name_style(&d).fg,
Some(Color::White),
"default palette command name → White"
);
assert_eq!(
help_label_style(&d).fg,
Some(Color::White),
"default help entry label → White"
);
}
#[test]
fn summary_line_heads_resolve_through_name_role() {
let t = audit_theme();
let issue = gwm::tui::issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Idle,
80,
&t,
);
assert_eq!(
fg_containing(&issue, "Issue #7"),
Some(t.name),
"issue summary head → name role"
);
let pr = gwm::tui::pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Idle,
80,
&t,
None,
);
assert_eq!(
fg_containing(&pr, "PR #9"),
Some(t.name),
"pr summary head → name role"
);
}
#[test]
fn summary_line_loaded_icons_resolve_through_state_roles() {
let t = audit_theme();
let issue_status = gwm::github::IssueStatus {
number: 7,
title: "closed".into(),
state: IssueState::Closed,
url: String::new(),
labels: vec![],
updated_at: String::new(),
};
let issue = gwm::tui::issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Loaded(issue_status),
80,
&t,
);
assert_eq!(
issue.spans[0].style.fg,
Some(issue_badge_color(IssueState::Closed, &t)),
"loaded issue icon → issue state role"
);
let pr_status = gwm::github::PrStatus {
number: 9,
title: "merged".into(),
state: PrState::Merged,
url: String::new(),
updated_at: String::new(),
checks_passed: 0,
checks_total: 0,
ci: CiState::None,
checks: vec![],
};
let pr = gwm::tui::pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Loaded(pr_status),
80,
&t,
None,
);
assert_eq!(
pr.spans[0].style.fg,
Some(pr_badge_color(PrState::Merged, &t)),
"loaded PR icon → PR state role"
);
}
#[test]
fn github_status_cached_state_icons_resolve_through_state_roles() {
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();
let t = audit_theme();
app.theme = t;
let lines = github_status_lines(&app, 120);
assert_eq!(
lines[0].spans[0].style.fg,
Some(issue_badge_color(IssueState::Closed, &t)),
"cached issue icon -> issue state role"
);
assert_eq!(
fg_containing(&lines[0], "closed"),
Some(issue_badge_color(IssueState::Closed, &t)),
"cached issue badge -> issue state role"
);
assert_eq!(
lines[1].spans[0].style.fg,
Some(pr_badge_color(PrState::Merged, &t)),
"cached PR icon -> PR state role"
);
assert_eq!(
fg_containing(&lines[1], "merged"),
Some(pr_badge_color(PrState::Merged, &t)),
"cached PR badge -> PR state role"
);
}
#[test]
fn summary_line_non_loaded_icons_stay_muted() {
let t = audit_theme();
let issue = gwm::tui::issue_summary_line(
7,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Idle,
80,
&t,
);
assert_eq!(issue.spans[0].style.fg, Some(t.muted), "idle issue icon → muted");
let pr = gwm::tui::pr_summary_line(
9,
gwm::github::LinkSource::Explicit,
&gwm::tui::GitHubFetchState::Error("offline".into()),
80,
&t,
None,
);
assert_eq!(pr.spans[0].style.fg, Some(t.muted), "error PR icon → muted");
}
#[test]
fn header_line_resolves_chrome_through_theme_roles() {
let t = audit_theme();
let line = header_line("repo", "/home/u/repo", true, 120, &t);
assert_eq!(fg_containing(&line, "gwm "), Some(t.accent), "version chip → accent");
assert_eq!(fg_containing(&line, "repo"), Some(t.name), "current-dir badge → name");
assert_eq!(fg_containing(&line, "picker"), Some(t.dirty), "picker chip → dirty");
assert_eq!(fg_containing(&line, "/home/u/repo"), Some(t.muted), "path → muted");
}
#[test]
fn footer_line_resolves_chrome_through_theme_roles() {
let t = audit_theme();
let hints = &[("n", "new")];
let line = footer_line(hints, "opened foo", 120, &t);
assert_eq!(fg_containing(&line, "n"), Some(t.accent), "key chip → accent");
assert_eq!(fg_containing(&line, "new"), Some(t.muted), "hint label → muted");
assert_eq!(fg_containing(&line, "opened foo"), Some(t.dirty), "status → dirty");
}
#[test]
fn commit_graph_node_and_connectors_resolve_through_branch_role() {
let t = audit_theme();
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, &t);
assert!(
spans.iter().any(|s| s.style.fg == Some(t.branch)),
"graph spans must paint with the branch role, got: {:?}",
spans
.iter()
.map(|s| (s.content.as_ref(), s.style.fg))
.collect::<Vec<_>>()
);
assert!(
spans.iter().all(|s| s.style.fg == Some(t.branch)),
"every graph span (node + connectors) must use the branch role"
);
}
#[test]
fn sidebar_identity_badges_resolve_through_theme_roles() {
let t = audit_theme();
let mut w = base_worktree("feat-170");
w.is_main = true;
w.is_locked = true;
w.is_prunable = true;
w.status = dirty_status();
let sections = build_sidebar_sections(&w, SidebarMode::Commits, None, &t);
let badge_fg = |needle: &str| -> Option<Color> {
sections
.worktree
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains(needle))
.and_then(|s| s.style.fg)
};
assert_eq!(badge_fg("★ main"), Some(t.main), "★ main badge → main role");
assert_eq!(badge_fg("🔒 locked"), Some(t.locked), "locked badge → locked role");
assert_eq!(
badge_fg("⚠ prunable"),
Some(t.prunable),
"prunable badge → prunable role"
);
assert_eq!(
badge_fg(w.branch.as_deref().unwrap()),
Some(t.prunable),
"dirty branch name → prunable role"
);
}
#[test]
fn sidebar_identity_default_theme_preserves_legacy_palette() {
let d = Theme::default();
let mut w = base_worktree("feat-170");
w.is_main = true;
w.is_locked = true;
w.is_prunable = true;
let sections = build_sidebar_sections(&w, SidebarMode::Commits, None, &d);
let badge_fg = |needle: &str| -> Option<Color> {
sections
.worktree
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains(needle))
.and_then(|s| s.style.fg)
};
assert_eq!(badge_fg("★ main"), Some(Color::Yellow));
assert_eq!(badge_fg("🔒 locked"), Some(Color::Magenta));
assert_eq!(badge_fg("⚠ prunable"), Some(Color::Red));
}
#[test]
fn gwm_toml_role_override_reaches_a_render_helper() {
let (dir, _) = init_repo();
std::fs::write(
dir.path().join(".gwm.toml"),
r##"
[theme]
branch = "#abcdef"
"##,
)
.unwrap();
let app = gwm::tui::App::new_at_layered(Some(dir.path()), None).unwrap();
let expected = Color::Rgb(0xab, 0xcd, 0xef);
assert_eq!(app.theme.branch, expected, "override must land on App.theme");
let synced = BranchStatus {
has_upstream: true,
..BranchStatus::default()
};
assert_eq!(
branch_name_color(&synced, &app.theme),
expected,
"the `branch` override must reach the branch-name render helper"
);
}
fn base_worktree(name: &str) -> WorktreeInfo {
WorktreeInfo {
name: name.into(),
id: name.into(),
path: PathBuf::from(format!("/tmp/gwm-theme-audit/{}", name)),
branch: Some(format!("feat/#170-{}", name)),
head: Some("0123456789abcdef0123456789abcdef01234567".into()),
is_main: false,
is_locked: false,
is_prunable: false,
status: BranchStatus::default(),
link: BranchLink::empty(),
issue_state: None,
pr_state: None,
age: Some(Duration::from_secs(3600)),
}
}