use crate::api::project::{FieldValue, Project, ProjectOwner, ProjectRef, ProjectsOf};
use crate::route::projects;
use super::*;
use crate::act::{By, Subject};
use crate::ui::page::Link;
fn roadmap() -> ProjectRef {
ProjectRef {
owner: ProjectOwner::Org("gold-silver-copper".into()),
number: 1,
}
}
pub(super) fn with_project(view: Option<u64>) -> State {
let mut s = sized(state(), 100, 40);
let mut p = fixtures::project();
if view == Some(2) {
p.view = 2;
p.items.items.retain(|it| it.number != 1004);
p.items.total = 4;
}
let route = Route::Projects(projects::Project {
project: roadmap(),
view,
});
open(&mut s, route, Data::Project(Box::new(p)));
s
}
fn selected(s: &State) -> String {
let Screen::Page(p) = s.screen() else {
panic!("not a page");
};
match p.selected_link() {
Some(Link::Url(url)) => url.clone(),
other => panic!("{other:?}"),
}
}
const ISSUE: &str = "https://github.com/gold-silver-copper/ghtui/issues/14";
#[test]
fn projects_are_reached_from_profiles_and_repositories() {
let projects = |s: &State| {
s.chrome()
.tabs
.into_iter()
.find(|(t, _)| t.label == "Projects")
.map(|(_, target)| target)
};
let s = with_profile(ProfileTab::Overview);
let user = ProjectsOf::Owner(ProjectOwner::User("octocat".into()));
assert_eq!(
projects(&s),
Some(crate::route::Target::Page(Route::Projects(
projects::Projects(user)
)))
);
let mut s = with_repo();
let linked = Route::Projects(projects::Projects(ProjectsOf::Repo(super::ghtui())));
assert_eq!(
projects(&s),
Some(crate::route::Target::Page(linked.clone()))
);
open(
&mut s,
linked,
Data::Projects(Box::new(fixtures::projects())),
);
let active = s
.chrome()
.active
.and_then(|i| s.chrome().tabs.get(i).cloned());
assert_eq!(active.map(|(t, _)| t.label), Some("Projects".to_owned()));
let text = page_text(&s);
assert!(
text.contains("Roadmap") && text.contains("Launch"),
"{text}"
);
press(&mut s, "<Enter>");
let expect = Route::Projects(projects::Project {
project: roadmap(),
view: None,
});
assert_eq!(s.route(), Some(&expect));
}
#[test]
fn h_and_l_go_between_columns() {
let mut s = with_project(None);
let someday = selected(&s);
assert!(someday.contains("itemId=1005"), "{someday}");
press(&mut s, "<Right>");
assert_eq!(selected(&s), ISSUE);
press(&mut s, "<Down><Right>");
assert_eq!(
selected(&s),
"https://github.com/gold-silver-copper/ghtui/pull/12"
);
press(&mut s, "<Right>");
assert_eq!(
selected(&s),
"https://github.com/gold-silver-copper/ghtui/issues/9"
);
press(&mut s, "<Right>");
says(&s, "No column to the right has cards");
press(&mut s, "<Left><Left><Left>");
assert_eq!(selected(&s), someday);
let mut s = with_project(Some(2));
press(&mut s, "<Right>");
says(&s, "Columns are a board's: this view is a table");
}
fn with_lanes() -> State {
let mut s = sized(state(), 100, 60);
let mut p = fixtures::project();
p.views.items[0].group = Some(NodeId::new("F_assignees"));
let route = Route::Projects(projects::Project {
project: roadmap(),
view: None,
});
open(&mut s, route, Data::Project(Box::new(p)));
s
}
#[test]
fn a_board_with_swimlanes_has_its_columns_in_each_lane() {
let mut s = with_lanes();
let text = page_text(&s);
assert!(text.contains("swimlanes by Assignees"), "{text}");
let lanes = ["No Assignees", " alice ", " bob "].map(|l| text.find(l));
assert!(lanes.iter().all(Option::is_some), "{text}");
assert!(lanes.is_sorted(), "{lanes:?}");
for _ in 0..20 {
if selected(&s) == ISSUE {
break;
}
press(&mut s, "<Down>");
}
assert_eq!(selected(&s), ISSUE);
press(&mut s, "<Right>");
says(&s, "No column to the right has cards");
assert_eq!(selected(&s), ISSUE);
}
#[test]
fn a_roadmap_puts_items_on_a_timeline() {
use crate::api::project::{FieldKind, Layout, ProjectField};
let mut s = sized(state(), 100, 30);
let mut p = fixtures::project();
p.view = 2;
p.views.items[1].layout = Layout::Roadmap;
p.fields.items.push(ProjectField {
id: NodeId::new("F_start"),
name: "Start date".into(),
kind: FieldKind::Date,
});
let date = |d: &str| (NodeId::new("F_start"), FieldValue::Date(d.into()));
p.items.items[0].values.push(date("2026-10-05"));
p.items.items[1].values.push(date("2026-10-12"));
let route = Route::Projects(projects::Project {
project: roadmap(),
view: Some(2),
});
open(&mut s, route, Data::Project(Box::new(p)));
let text = page_text(&s);
assert!(text.contains("On a timeline by Start date → Due"), "{text}");
assert!(text.contains("Oct 2026"), "{text}");
assert!(text.contains("no dates"), "{text}");
assert!(
text.contains("Oct 5 → 31") && text.contains("◆ Oct 12"),
"{text}"
);
}
#[test]
fn a_draft_shows_its_body() {
let mut s = with_project(None);
press(&mut s, "<Right><Down><Enter>");
let expect = Route::Projects(projects::ProjectItem {
project: roadmap(),
view: None,
item: 1003,
});
assert_eq!(s.route(), Some(&expect));
let text = page_text(&s);
assert!(text.contains("Write the board's docs"), "{text}");
assert!(
text.contains("Explain h and l, and moving cards."),
"{text}"
);
assert!(text.contains("Status") && text.contains("Todo"), "{text}");
}
#[test]
fn moving_a_card_sets_its_column_and_the_board_follows() {
let mut s = with_project(None);
press(&mut s, "<Right>");
assert!(press(&mut s, "m").is_empty());
let Some(Overlay::Picker(picker)) = &s.overlay else {
panic!("no picker");
};
let rows = s.picker_rows(picker);
let labels: Vec<(&str, &str)> = (rows.iter())
.map(|(r, _)| (r.label.as_str(), r.hint.as_str()))
.collect();
assert_eq!(
labels,
[
("Todo", "current"),
("In Progress", ""),
("Done", "Shipped"),
("No Status", "")
]
);
let sent = press(&mut s, "Done<Enter>");
let done = FieldValue::Option {
id: "O_done".into(),
name: "Done".into(),
};
let change = Change::SetProjectField {
project: NodeId::new("PVT_1"),
item: NodeId::new("PVTI_1"),
field: NodeId::new("F_status"),
value: Some(done.clone()),
said: "Status to Done".into(),
};
let about = Subject::Project(roadmap(), None);
assert_eq!(sent, vec![fixtures::change(change, By::Act(about))]);
answer(&mut s, &sent, Ok(()));
says(&s, "Set Status to Done");
let key = DataKey::Project(roadmap(), None);
let mut p: Project = fixtures::project();
fetched(&mut s, key.clone(), Data::Project(Box::new(p.clone())));
assert!(s.following.is_some());
press(&mut s, "m");
says(&s, "GitHub hasn't shown the last change yet");
if let Some(it) = p.items.items.first_mut() {
it.values.retain(|(f, _)| f.as_str() != "F_status");
it.values.push((NodeId::new("F_status"), done));
}
fetched(&mut s, key, Data::Project(Box::new(p)));
assert!(s.following.is_none());
let text = page_text(&s);
let (done_at, issue_at) = (text.find(" Done "), text.find("Projects aren't there yet"));
assert!(done_at < issue_at, "{text}");
}
#[test]
fn setting_a_field_sends_its_value() {
let mut s = with_project(Some(2));
act(&mut s, Action::SetField);
let Some(Overlay::Picker(picker)) = &s.overlay else {
panic!("no picker");
};
let fields: Vec<String> = (s.picker_rows(picker).into_iter())
.map(|(r, _)| r.label)
.collect();
assert_eq!(fields, ["Status", "Estimate", "Sprint", "Due"]);
press(&mut s, "Esti<Enter>x");
let Some(Overlay::Picker(picker)) = &s.overlay else {
panic!("no picker");
};
let rows = s.picker_rows(picker);
assert_eq!(
rows.first().map(|(r, _)| r.label.as_str()),
Some("“x” isn't a number")
);
assert!(rows.first().is_some_and(|(_, chosen)| chosen.is_none()));
let sent = press(&mut s, "<BS>8<Enter>");
let (Change::SetProjectField { field, value, .. }, _) = fixtures::sent_change(&sent) else {
panic!("{sent:?}");
};
assert_eq!(field.as_str(), "F_estimate");
assert_eq!(value, &Some(FieldValue::Number("8".into())));
}
#[test]
fn a_project_you_cant_change_says_so() {
let mut s = state();
let mut p = fixtures::project();
p.can_update = false;
let route = Route::Projects(projects::Project {
project: roadmap(),
view: None,
});
open(&mut s, route, Data::Project(Box::new(p)));
assert!(press(&mut s, "m").is_empty());
says(&s, "GitHub doesn't let you change this project");
assert!(s.overlay.is_none());
}
fn change_sent(cmds: &[Cmd]) -> &Change {
fixtures::sent_change(cmds).0
}
#[test]
fn items_are_added_by_reference_or_as_drafts() {
use crate::api::project::{ItemContent, ProjectChange};
let mut s = with_project(None);
press(&mut s, "a");
press(&mut s, "gold-silver-copper/ghtui#14");
let Some(Overlay::Picker(picker)) = &s.overlay else {
panic!("no picker");
};
let rows = s.picker_rows(picker);
assert_eq!(rows[0].0.hint, "already in the project");
press(&mut s, "<C-u>gold-silver-copper/ghtui#20");
let sent = press(&mut s, "<Enter>");
let add = Change::Project(ProjectChange::Add {
project: NodeId::new("PVT_1"),
repo: crate::api::model::RepoId::new("gold-silver-copper", "ghtui"),
number: 20,
});
assert_eq!(change_sent(&sent), &add);
answer(&mut s, &sent, Ok(()));
says(&s, "Added to the project");
assert!(s.following.is_some());
let key = DataKey::Project(roadmap(), None);
let mut p = fixtures::project();
let mut added = p.items.items[0].clone();
if let ItemContent::Issue(l, _) = &mut added.content {
l.number = 20;
}
p.items.items.push(added);
fetched(&mut s, key, Data::Project(Box::new(p)));
assert!(s.following.is_none());
press(&mut s, "a");
press(&mut s, "Plan the release<Enter>");
assert!(matches!(s.overlay, Some(Overlay::Compose(_))));
let sent = press(&mut s, "Dates and notes<C-s>");
let draft = Change::Project(ProjectChange::AddDraft {
project: NodeId::new("PVT_1"),
title: "Plan the release".into(),
body: "Dates and notes".into(),
});
assert_eq!(change_sent(&sent), &draft);
}
#[test]
fn items_are_archived_and_restored() {
use crate::api::project::ProjectChange;
let mut s = with_project(None);
press(&mut s, "<Right>");
assert!(act(&mut s, Action::ArchiveItem).is_empty());
let Some(Overlay::Ask(c)) = &s.overlay else {
panic!("not asked");
};
assert_eq!(c.title, "Archive “Projects aren't there yet”?");
let sent = press(&mut s, "<Enter>");
let archive = Change::Project(ProjectChange::Archive {
project: NodeId::new("PVT_1"),
item: NodeId::new("PVTI_1"),
archived: true,
});
assert_eq!(change_sent(&sent), &archive);
answer(&mut s, &sent, Ok(()));
let key = DataKey::Project(roadmap(), None);
let mut p = fixtures::project();
let mut gone = p.items.items.remove(0);
gone.archived = true;
p.archived.items.push(gone);
fetched(&mut s, key, Data::Project(Box::new(p)));
assert!(s.following.is_none());
press(&mut s, "<End>");
while !selected(&s).contains("itemId=1006") {
press(&mut s, "<Up>");
}
let sent = act(&mut s, Action::ArchiveItem);
let restore = Change::Project(ProjectChange::Archive {
project: NodeId::new("PVT_1"),
item: NodeId::new("PVTI_6"),
archived: false,
});
assert_eq!(change_sent(&sent), &restore);
}
#[test]
fn a_drafts_title_and_text_are_edited() {
use crate::api::project::ProjectChange;
let mut s = with_project(None);
press(&mut s, "<Right><Down>");
press(&mut s, "e");
let Some(Overlay::Compose(c)) = &s.overlay else {
panic!("no composer");
};
assert_eq!(
c.text(),
"Write the board's docs\n\nExplain **h** and **l**, and moving cards."
);
let sent = press(&mut s, "<C-s>");
let edit = Change::Project(ProjectChange::EditDraft {
draft: NodeId::new("DI_3"),
title: "Write the board's docs".into(),
body: "Explain **h** and **l**, and moving cards.".into(),
});
assert_eq!(change_sent(&sent), &edit);
s.overlay = None;
press(&mut s, "<Up>e");
says(&s, "Only a draft's text is edited here");
}
#[test]
fn an_issues_field_is_set_from_its_sidebar() {
let mut s = super::with_issue();
let said = "Roadmap (In progress · Estimate: 3)";
assert!(page_text(&s).contains(said), "{}", page_text(&s));
act(&mut s, Action::SetField);
let Some(Overlay::Picker(picker)) = &s.overlay else {
panic!("no picker");
};
let fields: Vec<(String, String)> = (s.picker_rows(picker).into_iter())
.map(|(r, _)| (r.label, r.hint))
.collect();
let want =
[("Status", "In progress"), ("Estimate", "3")].map(|(f, v)| (f.to_owned(), v.to_owned()));
assert_eq!(fields, want);
press(&mut s, "Status<Enter>");
let sent = press(&mut s, "Done<Enter>");
let done = FieldValue::Option {
id: "done".into(),
name: "Done".into(),
};
let change = Change::SetProjectField {
project: NodeId::new("PVT_1"),
item: NodeId::new("PVTI_14"),
field: NodeId::new("F_status"),
value: Some(done.clone()),
said: "Status to Done".into(),
};
let about = Subject::Issue(super::ghtui(), 14);
assert_eq!(sent, vec![fixtures::change(change, By::Act(about))]);
answer(&mut s, &sent, Ok(()));
let key = DataKey::InProjects(super::ghtui(), 14);
let polled = crate::follow::poll(&mut s);
assert!(polled.contains(&key), "{polled:?}");
let mut projects = fixtures::in_projects();
let unchanged = Data::InProjects(Box::new(projects.clone()));
fetched(&mut s, key.clone(), unchanged);
assert!(s.following.is_some());
projects.items[0].values[0].1 = done;
fetched(&mut s, key, Data::InProjects(Box::new(projects)));
assert!(s.following.is_none());
let said = "Roadmap (Done · Estimate: 3)";
assert!(page_text(&s).contains(said), "{}", page_text(&s));
}