use crate::route::{code, issues, pulls};
use std::path::PathBuf;
use crate::api::housekeeping::{
BranchRules, FileLook, LabelChange, MadeRepo, MilestoneChange, NewFork, NewRepo, RefKind,
};
use crate::housekeeping::{FileChange, FormOf, MilestoneWrite, RefWrite};
use crate::housekeeping::{files, labels, milestones, refs, repos};
use super::*;
use crate::fixtures::{self};
fn picker(s: &State) -> bool {
matches!(&s.overlay, Some(Overlay::Picker(_)))
}
fn select(s: &mut State, text: &str) {
press(s, "<Home>");
for _ in 0..200 {
let crate::state::Screen::Page(p) = s.screen() else {
panic!("not a page");
};
let on = p
.selected
.and_then(|i| p.page().items.get(i))
.is_some_and(|item| {
let line = p
.page()
.lines
.get(item.start)
.map(crate::ui::page::PageLine::text);
line.is_some_and(|l| l.contains(text))
});
if on {
return;
}
press(s, "<Down>");
}
panic!("no row has {text}");
}
fn with_labels() -> State {
at(with_repo(), Route::Issues(issues::Labels(ghtui())))
}
fn labels_key() -> DataKey {
DataKey::Labels(ghtui())
}
#[test]
fn a_label_is_made_and_the_page_follows() {
let mut s = with_labels();
let screen = screen_text(&s);
assert!(
screen.contains("good first issue") && screen.contains("12 open"),
"{screen}"
);
act(&mut s, Action::NewHere);
let form = Route::Housekeeping(FormOf::Label {
repo: ghtui(),
name: None,
});
assert_eq!(s.route(), Some(&form));
assert!(picker(&s), "its name first");
press(&mut s, "triage<Enter>");
select(&mut s, "Colour");
press(&mut s, "<Enter><C-u>#ABCDEF<Enter>");
let cmds = press(&mut s, "<C-s>");
let work = fixtures::sent::<labels::Save>(&cmds);
let made = LabelChange {
name: "triage".into(),
color: "abcdef".into(),
description: String::new(),
};
assert_eq!((&work.was, &work.change), (&None, &Some(made)));
fixtures::done(&mut s, work, Ok(()));
assert_eq!(
s.route(),
Some(&Route::Issues(issues::Labels(ghtui()))),
"back on the labels"
);
says(&s, "Created the label triage");
assert!(s.busy().is_some_and(|b| b.contains("Waiting for GitHub")));
let mut labels = crate::housekeeping::fixtures::labels();
labels.items.push(crate::api::housekeeping::LabelInfo {
name: "triage".into(),
color: "ABCDEF".into(),
description: String::new(),
open: 0,
});
let data = Data::Labels(Box::new(labels));
fetched(&mut s, labels_key(), data);
assert!(s.following.is_none(), "GitHub lists it");
}
#[test]
fn a_label_is_edited_and_deleted() {
let mut s = with_labels();
select(&mut s, "good first issue");
press(&mut s, "e");
let cmds = press(&mut s, "<C-s>");
let work = fixtures::sent::<labels::Save>(&cmds);
assert_eq!(work.was.as_deref(), Some("good first issue"));
let change = work.change.clone().unwrap();
assert_eq!(change.color, "7057ff");
assert_eq!(change.description, "Good for newcomers");
fixtures::done(&mut s, work, Ok(()));
assert_eq!(s.route(), Some(&Route::Issues(issues::Labels(ghtui()))));
select(&mut s, "bug");
press(&mut s, "D");
let ask = overlay!(s, Ask);
assert_eq!(ask.title, "Delete the label bug?");
assert!(
ask.facts
.iter()
.any(|(f, _)| f.contains("its 4 open issues and pull requests")),
"{:?}",
ask.facts
);
let cmds = press(&mut s, "<Enter>");
assert_eq!(
fixtures::sent::<labels::Save>(&cmds),
labels::Save {
repo: ghtui(),
was: Some("bug".into()),
change: None,
}
);
}
#[test]
fn milestones_are_closed_edited_and_deleted() {
let mut s = with_milestones();
select(&mut s, "Pages");
let cmds = press(&mut s, "x");
let close = milestones::Save {
repo: ghtui(),
number: Some(5),
write: MilestoneWrite::Close(true),
};
assert_eq!(fixtures::sent::<milestones::Save>(&cmds), close);
fixtures::done(&mut s, close, Ok(Some(5)));
says(&s, "Closed the milestone");
let mut list = fixtures::milestones();
list.results.items.retain(|m| m.number != 5);
let key = DataKey::Milestones(ghtui(), false);
fetched(&mut s, key, Data::Milestones(Box::new(list)));
assert!(s.following.is_none(), "gone from the open ones");
select(&mut s, "Links");
press(&mut s, "e");
let form = Route::Housekeeping(FormOf::Milestone {
repo: ghtui(),
number: Some(3),
});
assert_eq!(s.route(), Some(&form));
fetched(
&mut s,
DataKey::Milestone(ghtui(), 3),
Data::Milestone(Box::new(fixtures::milestone())),
);
assert!(page_text(&s).contains("2026-11-05"), "{}", page_text(&s));
select(&mut s, "Due date");
press(&mut s, "<Enter>2026-13-01");
assert!(page_text(&s).contains("Due date"));
press(&mut s, "<C-u>2027-01-31<Enter>");
let cmds = press(&mut s, "<C-s>");
let work = fixtures::sent::<milestones::Save>(&cmds);
let saved = MilestoneChange {
title: "Links".into(),
description: fixtures::milestone().info.description,
due_on: Some("2027-01-31".into()),
};
assert_eq!(work.write, MilestoneWrite::Save(saved));
fixtures::done(&mut s, work, Ok(Some(3)));
assert_eq!(
s.route(),
Some(&Route::Issues(issues::Milestones {
repo: ghtui(),
closed: false
}))
);
select(&mut s, "Links");
press(&mut s, "D");
assert_eq!(overlay!(s, Ask).title, "Delete the milestone Links?");
let cmds = press(&mut s, "<Enter>");
let delete = milestones::Save {
repo: ghtui(),
number: Some(3),
write: MilestoneWrite::Delete,
};
assert_eq!(fixtures::sent::<milestones::Save>(&cmds), delete);
}
#[test]
fn branches_are_made_and_protected_ones_kept() {
let mut s = with_branches();
let repo = ghtui();
let cmds = fixtures::cloned(&mut s, &repo, None);
assert!(cmds.is_empty(), "{cmds:?}");
select(&mut s, "tabs");
act(&mut s, Action::NewHere);
assert!(picker(&s), "its name first");
press(&mut s, "topic<Enter>");
let cmds = press(&mut s, "<Enter>");
assert_eq!(
fixtures::sent::<refs::Save>(&cmds),
refs::Save {
repo: repo.clone(),
kind: RefKind::Branch,
name: "topic".into(),
write: RefWrite::Create("tabs".into()),
clone: None,
}
);
select(&mut s, "main");
press(&mut s, "D");
says(&s, "main is the default branch");
select(&mut s, "links");
let cmds = press(&mut s, "D");
let key = DataKey::BranchRules(repo.clone(), "links".into());
assert_eq!(fixtures::asked(&cmds), std::slice::from_ref(&key));
let rules = BranchRules {
protected: false,
undeletable: true,
};
fixtures::fetched(&mut s, key, Data::BranchRules(Box::new(rules)));
says(
&s,
"a ruleset of the repository's keeps it from being deleted",
);
assert!(s.overlay.is_none());
}
#[test]
fn in_a_clone_git_pushes_after_asking() {
let mut s = with_branches();
let repo = ghtui();
let clone = crate::git::local::Clone {
top: PathBuf::from("/work/ghtui"),
remote: "origin".into(),
branch: Some("main".into()),
};
fixtures::cloned(&mut s, &repo, Some(clone.clone()));
select(&mut s, "tabs");
let cmds = press(&mut s, "D");
let rules = BranchRules {
protected: false,
undeletable: false,
};
let key = DataKey::BranchRules(repo.clone(), "tabs".into());
fixtures::fetched(&mut s, key.clone(), Data::BranchRules(Box::new(rules)));
assert_eq!(fixtures::asked(&cmds), [key]);
let ask = overlay!(s, Ask);
assert_eq!(ask.title, "Delete the branch tabs?");
assert!(
ask.facts
.iter()
.any(|(f, _)| f.contains("Your clone's own branch tabs stays")),
"{:?}",
ask.facts
);
let cmds = press(&mut s, "<Enter>");
let delete = refs::Save {
repo: repo.clone(),
kind: RefKind::Branch,
name: "tabs".into(),
write: RefWrite::Delete,
clone: Some(clone),
};
assert_eq!(fixtures::sent::<refs::Save>(&cmds), delete);
fixtures::done(&mut s, delete, Ok(()));
says(&s, "Deleted the branch tabs");
let mut branches = fixtures::branches();
branches.items.retain(|b| b.name != "tabs");
fetched(
&mut s,
DataKey::Branches(repo),
Data::Branches(Box::new(branches)),
);
assert!(s.following.is_none());
}
#[test]
fn a_repository_is_forked_and_opened() {
let mut s = with_repo();
act(&mut s, Action::Fork);
let form = Route::Housekeeping(FormOf::Fork(ghtui()));
assert_eq!(s.route(), Some(&form));
let owners = Data::Owners(Box::new(crate::housekeeping::fixtures::owners()));
fetched(&mut s, DataKey::Owners, owners);
assert!(page_text(&s).contains("It will be octocat/ghtui"));
let cmds = press(&mut s, "<C-s>");
let new = NewFork {
repo: ghtui(),
org: None,
name: "ghtui".into(),
default_branch_only: true,
};
let fork = repos::Fork { route: form, new };
assert_eq!(fixtures::sent::<repos::Fork>(&cmds), fork);
let made = MadeRepo {
full_name: "octocat/ghtui".into(),
clone_url: "https://github.com/octocat/ghtui.git".into(),
};
fixtures::done(&mut s, fork, Ok(made));
let fork = crate::api::model::RepoId::new("octocat", "ghtui");
assert_eq!(s.route(), Some(&Route::Code(code::Repo(fork))));
assert!(s.busy().is_some_and(|b| b.contains("make the repository")));
}
#[test]
fn a_new_repository_is_made_and_cloning_offered() {
let mut s = with_repo();
act(&mut s, Action::NewRepo);
let form = Route::Housekeeping(FormOf::NewRepo);
assert_eq!(s.route(), Some(&form));
let owners = Data::Owners(Box::new(crate::housekeeping::fixtures::owners()));
fetched(&mut s, DataKey::Owners, owners);
let templates = Data::Templates(Box::new(crate::housekeeping::fixtures::templates()));
fetched(&mut s, DataKey::Templates, templates);
assert!(picker(&s), "its name first");
press(&mut s, "tools<Enter>");
select(&mut s, "Owner");
press(&mut s, "<Enter>readers");
let readers = crate::housekeeping::rows(&s, &picked(&s), "readers");
assert!(
readers.iter().all(|(_, chosen)| chosen.is_none()),
"not yours to make in"
);
press(&mut s, "<C-u>ghtui-org<Enter>");
select(&mut s, "Visibility");
press(&mut s, "<Enter>");
select(&mut s, "Licence");
press(&mut s, "<Enter>MIT<Enter>");
let cmds = press(&mut s, "<C-s>");
let new = NewRepo {
org: Some("ghtui-org".into()),
name: "tools".into(),
description: String::new(),
private: true,
readme: false,
gitignore: None,
license: Some("mit".into()),
};
let make = repos::Make { route: form, new };
assert_eq!(fixtures::sent::<repos::Make>(&cmds), make);
let made = MadeRepo {
full_name: "ghtui-org/tools".into(),
clone_url: "https://github.com/ghtui-org/tools.git".into(),
};
fixtures::done(&mut s, make, Ok(made));
let repo = crate::api::model::RepoId::new("ghtui-org", "tools");
assert_eq!(s.route(), Some(&Route::Code(code::Repo(repo))));
assert_eq!(overlay!(s, Ask).title, "Clone ghtui-org/tools?");
let cmds = press(&mut s, "<Enter>");
assert_eq!(
fixtures::sent::<repos::CloneTo>(&cmds),
repos::CloneTo {
url: "https://github.com/ghtui-org/tools.git".into(),
dir: PathBuf::from("tools"),
}
);
}
fn picked(s: &State) -> crate::housekeeping::Pick {
match &s.overlay {
Some(Overlay::Picker(p)) => match &p.kind {
crate::picker::Kind::Housekeeping(pick) => pick.clone(),
_ => panic!("not a housekeeping picker"),
},
_ => panic!("no picker"),
}
}
fn look() -> FileLook {
fixtures::file_look()
}
#[test]
fn a_file_is_edited_and_committed_on_a_new_branch() {
let mut s = with_file();
fixtures::cloned(&mut s, &ghtui(), None);
let cmds = press(&mut s, "e");
let form = Route::Housekeeping(FormOf::File {
repo: ghtui(),
rev: "main".into(),
path: "src/main.rs".into(),
change: FileChange::Edit,
});
assert_eq!(s.route(), Some(&form));
let file = DataKey::FileLook(ghtui(), "main".into(), "src/main.rs".into());
assert!(fixtures::asks(&cmds, &file), "{cmds:?}");
read(&mut s);
let Some(Overlay::Compose(c)) = &mut s.overlay else {
panic!("no composer");
};
assert!(c.text().contains("Says hello."));
c.input.insert_str("// Edited.\n");
press(&mut s, "<Esc>");
select(&mut s, "Commit to");
press(&mut s, "<Enter>octocat-patch-1<Enter>");
press(&mut s, "<C-s>");
let ask = overlay!(s, Ask);
assert_eq!(ask.title, "Commit changes?");
assert!(
ask.facts
.iter()
.any(|(f, _)| f.contains("a new branch, octocat-patch-1"))
);
assert!(ask.facts.iter().any(|(f, _)| f == "On GitHub, by you"));
let cmds = press(&mut s, "<Enter>");
let commit = fixtures::sent::<files::Commit>(&cmds);
let write = &commit.write;
assert_eq!(commit.route, form);
assert_eq!(write.commit.branch, "octocat-patch-1");
assert_eq!(write.commit.from.as_deref(), Some(look().commit.as_str()));
assert_eq!(
write.commit.seen,
[("src/main.rs".into(), Some("b10b".into()))]
);
assert_eq!(write.commit.message, "Update main.rs");
let text = &write.commit.additions[0].1;
assert!(text.starts_with("// Edited.\nuse std::io;"), "{text}");
fixtures::done(&mut s, commit, Ok(()));
let page = Route::blob(ghtui(), "octocat-patch-1".into(), "src/main.rs".into());
assert_eq!(s.route(), Some(&page));
assert_eq!(
overlay!(s, Ask).title,
"Open a pull request for octocat-patch-1?"
);
press(&mut s, "<Enter>");
assert_eq!(s.route(), Some(&Route::Pulls(pulls::NewPr(ghtui()))));
let pr = (s
.forms
.get::<crate::create::Form>(&Route::Pulls(pulls::NewPr(ghtui()))))
.unwrap();
assert_eq!(pr.head.as_deref(), Some("octocat-patch-1"));
}
#[test]
fn a_file_changed_meanwhile_keeps_your_text() {
let mut s = with_file();
fixtures::cloned(&mut s, &ghtui(), None);
press(&mut s, "e");
let form = Route::Housekeeping(FormOf::File {
repo: ghtui(),
rev: "main".into(),
path: "src/main.rs".into(),
change: FileChange::Edit,
});
read(&mut s);
let Some(Overlay::Compose(c)) = &mut s.overlay else {
panic!("no composer");
};
c.input.insert_str("Mine. ");
press(&mut s, "<C-s>");
let cmds = press(&mut s, "<Enter>");
let commit = fixtures::sent::<files::Commit>(&cmds);
let write = &commit.write;
assert_eq!(write.commit.branch, "main");
assert_eq!(write.commit.from, None);
let why = "src/main.rs changed on GitHub since you opened it: committing would overwrite that";
fixtures::done(&mut s, commit, Err(fixtures::failure(why)));
assert_eq!(s.route(), Some(&form));
assert!(page_text(&s).contains("changed on GitHub since you opened it"));
let kept = &s
.forms
.get::<crate::housekeeping::Form>(&form)
.unwrap()
.text;
assert!(kept.starts_with("Mine. "), "{kept}");
}
#[test]
fn a_file_is_deleted_with_a_commit() {
let mut s = with_file();
fixtures::cloned(&mut s, &ghtui(), None);
press(&mut s, "D");
let form = Route::Housekeeping(FormOf::File {
repo: ghtui(),
rev: "main".into(),
path: "src/main.rs".into(),
change: FileChange::Delete,
});
assert_eq!(s.route(), Some(&form));
read(&mut s);
press(&mut s, "<C-s>");
assert_eq!(overlay!(s, Ask).title, "Delete src/main.rs?");
let cmds = press(&mut s, "<Enter>");
let commit = fixtures::sent::<files::Commit>(&cmds);
let write = &commit.write;
assert_eq!(write.commit.deletions, ["src/main.rs"]);
assert!(write.commit.additions.is_empty());
assert_eq!(write.commit.message, "Delete main.rs");
fixtures::done(&mut s, commit, Ok(()));
let folder = Route::Code(code::Tree {
repo: ghtui(),
rev: "main".into(),
path: "src".into(),
});
assert_eq!(s.route(), Some(&folder));
}
#[test]
fn without_write_access_each_says_why() {
let mut s = with_file();
let mut overview = fixtures::overview();
overview.can_write = false;
fetched(
&mut s,
DataKey::Repo(ghtui()),
Data::Repo(Box::new(overview)),
);
press(&mut s, "e");
says(
&s,
"Changing its files takes write access to gold-silver-copper/ghtui: fork it (:fork)",
);
let route = Route::Issues(issues::Labels(ghtui()));
let _ = s.push(route);
act(&mut s, Action::NewHere);
says(&s, "Changing labels takes write access");
}
#[test]
fn a_file_form_opened_from_a_page_reads_the_file() {
let mut s = with_repo();
let cmds = press(
&mut s,
":gold-silver-copper/ghtui/edit/main/README.md<Enter>",
);
let file = DataKey::FileLook(ghtui(), "main".into(), "README.md".into());
assert!(fixtures::asks(&cmds, &file), "{cmds:?}");
}
fn read(s: &mut State) -> Vec<Cmd> {
let file = DataKey::FileLook(ghtui(), "main".into(), "src/main.rs".into());
fixtures::fetched(s, file, Data::FileLook(Box::new(look())))
}