use super::*;
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
fn board(owner: &str, number: i64, title: &str) -> GitHubProject {
serde_json::from_value(serde_json::json!({
"id": format!("PVT_{owner}_{number}"),
"number": number,
"title": title,
"url": format!("https://github.com/orgs/{owner}/projects/{number}"),
"short_description": null,
"closed": false,
"updated_at": "2026-10-02T10:00:00Z",
"owner": owner,
"fields": [
{ "id": "F_title", "name": "Title", "kind": "title", "options": [] },
{ "id": "F_assignees", "name": "Assignees", "kind": "assignees", "options": [] },
{ "id": "F_status", "name": "Status", "kind": "single_select", "options": [
{ "id": "o_backlog", "name": "Backlog", "color": "GREEN", "description": "" },
{ "id": "o_ready", "name": "Ready", "color": "BLUE", "description": "" },
{ "id": "o_done", "name": "Done", "color": "ORANGE", "description": "" }
] },
{ "id": "F_priority", "name": "Priority", "kind": "single_select", "options": [
{ "id": "o_p1", "name": "P1", "color": "ORANGE", "description": "" },
{ "id": "o_p2", "name": "P2", "color": "YELLOW", "description": "" }
] },
{ "id": "F_estimate", "name": "Estimate", "kind": "number", "options": [] },
{ "id": "F_target", "name": "Target date", "kind": "date", "options": [] },
{ "id": "F_notes", "name": "Notes", "kind": "text", "options": [] },
{ "id": "F_sprint", "name": "Sprint", "kind": "iteration", "options": [] }
]
}))
.expect("a board as the mirror holds one")
}
fn card(id: &str, title: &str, values: serde_json::Value) -> GitHubProjectItem {
serde_json::from_value(serde_json::json!({
"id": id,
"kind": "DRAFT_ISSUE",
"title": title,
"content_id": format!("DI_{id}"),
"body": "",
"field_values": values,
"updated_at": "2026-10-02T10:00:00Z"
}))
.expect("a card as the mirror holds one")
}
#[test]
fn test12405_a_written_value_is_the_kind_its_field_holds() {
let board = board("machinefabric", 1, "launch");
let write = |assignment: &str| field_write(&board, assignment);
assert_eq!(
write("status=ready").unwrap(),
(
"F_status".to_string(),
ProjectFieldWrite::SingleSelect { option_id: "o_ready".to_string() }
)
);
assert_eq!(
write("Estimate=2026").unwrap(),
("F_estimate".to_string(), ProjectFieldWrite::Number { number: 2026.0 })
);
assert_eq!(
write("Notes=2026").unwrap(),
("F_notes".to_string(), ProjectFieldWrite::Text { text: "2026".to_string() })
);
assert_eq!(
write("Notes= a = b ").unwrap().1,
ProjectFieldWrite::Text { text: " a = b ".to_string() }
);
assert_eq!(
write("Target date=2028-02-29").unwrap().1,
ProjectFieldWrite::Date { date: "2028-02-29".to_string() }
);
let refused = |assignment: &str| match write(assignment) {
Err(Refusal::Failed(message)) => message,
other => panic!("'{assignment}' was not refused as a value: {other:?}"),
};
let unknown = refused("Status=Shipping");
assert!(
unknown.contains("Backlog, Ready, Done"),
"an option the field lacks must be answered with the ones it has: {unknown}"
);
assert!(refused("Estimate=three").contains("not a number"));
assert!(refused("Estimate=inf").contains("finite"));
for date in ["2027-02-29", "2026-13-01", "2026-1-1", "tomorrow", "2026-04-31"] {
assert!(refused(&format!("Target date={date}")).contains("YYYY-MM-DD"), "{date}");
}
assert!(refused("Sprint=next").contains("iteration"));
assert!(refused("Title=new").contains("project edit"));
assert!(refused("Assignees=me").contains("not set from here"));
assert!(refused("Size=XL").contains("has no field 'Size'"));
assert!(matches!(write("Status"), Err(Refusal::Usage(_))));
}
#[test]
fn test12406_a_board_is_named_by_number_title_or_id() {
let boards = vec![
board("machinefabric", 1, "launch"),
board("machinefabric", 2, "Launch notes"),
board("someone", 1, "personal"),
];
let named = |name: &str| find_board(&boards, name).map(|board| board.id.clone());
assert_eq!(named("PVT_machinefabric_2").unwrap(), "PVT_machinefabric_2");
assert_eq!(named("2").unwrap(), "PVT_machinefabric_2");
assert_eq!(named("#2").unwrap(), "PVT_machinefabric_2");
assert_eq!(named("someone/1").unwrap(), "PVT_someone_1");
assert_eq!(named("LAUNCH").unwrap(), "PVT_machinefabric_1");
assert_eq!(named("pers").unwrap(), "PVT_someone_1");
let shared = named("1").unwrap_err();
assert!(
shared.contains("machinefabric/1") && shared.contains("someone/1"),
"number 1 is two boards here, and both must be named: {shared}"
);
assert!(named("la").unwrap_err().contains("2 boards"));
let nothing = named("roadmap").unwrap_err();
assert!(nothing.contains("machinefabric/1 'launch'"), "{nothing}");
}
#[test]
fn test12407_a_card_is_named_by_id_or_title_and_never_guessed() {
let mut linked = card("PVTI_bbb222", "Fix the parser", serde_json::json!({}));
linked.url = Some("https://github.com/machinefabric/capdag/issues/7".to_string());
let cards = vec![
card("PVTI_aaa111", "Write the post", serde_json::json!({})),
linked,
card("PVTI_aaa999", "Write the post", serde_json::json!({})),
card("PVTI_ccc333", "PVTI_bbb", serde_json::json!({})),
];
let named = |name: &str| find_item(&cards, name).map(|card| card.id.clone());
assert_eq!(named("PVTI_aaa999").unwrap(), "PVTI_aaa999");
assert_eq!(named("Fix the parser").unwrap(), "PVTI_bbb222");
assert_eq!(named("https://github.com/machinefabric/capdag/issues/7").unwrap(), "PVTI_bbb222");
assert_eq!(named("PVTI_bbb2").unwrap(), "PVTI_bbb222");
assert_eq!(named("PVTI_bbb").unwrap(), "PVTI_ccc333");
let twice = named("Write the post").unwrap_err();
assert!(
twice.contains("PVTI_aaa111") && twice.contains("PVTI_aaa999"),
"two cards share the title, and both ids must be given: {twice}"
);
assert!(named("PVTI_aaa").unwrap_err().contains("2 cards"));
assert!(named("write the post").unwrap_err().contains("no card"), "a title is exact");
}
#[test]
fn test12408_a_card_is_reported_by_field_name_for_both_readers() {
let board = board("machinefabric", 1, "launch");
let ready = card(
"PVTI_1",
"Add the model",
serde_json::json!({
"F_title": { "kind": "text", "text": "Add the model" },
"F_priority": { "kind": "single_select", "option_id": "o_p1", "name": "P1" },
"F_status": { "kind": "single_select", "option_id": "o_ready", "name": "Ready" },
"F_estimate": { "kind": "number", "number": 3.0 },
"F_target": { "kind": "date", "date": "2026-11-01" }
}),
);
let loose = card("PVTI_2", "Somewhere", serde_json::json!({ "F_estimate": { "kind": "number", "number": 0.5 } }));
let backlog = card(
"PVTI_3",
"Later",
serde_json::json!({ "F_status": { "kind": "single_select", "option_id": "o_backlog", "name": "Backlog" } }),
);
let Record::ProjectItem { item, title, fields, url, .. } = item_record(&board, &ready) else {
panic!("a card is a project_item record");
};
assert_eq!((item.as_str(), title.as_str(), url), ("PVTI_1", "Add the model", None));
let said: Vec<(&str, &str, &str, Option<&str>)> = fields
.iter()
.map(|v| (v.field.as_str(), v.kind.as_str(), v.value.as_str(), v.option.as_deref()))
.collect();
assert_eq!(
said,
vec![
("Status", "single_select", "Ready", Some("o_ready")),
("Priority", "single_select", "P1", Some("o_p1")),
("Estimate", "number", "3", None),
("Target date", "date", "2026-11-01", None),
],
"by name, in the board's field order, the title left out"
);
assert_eq!(named_values(&board, &loose)[0].value, "0.5");
let prose = items_prose(&board, &[&loose, &ready, &backlog]);
let headings: Vec<&str> = prose.iter().filter(|line| !line.starts_with(" ")).map(String::as_str).collect();
assert_eq!(
headings,
vec!["launch — 3 card(s), by Status", "Backlog (1)", "Ready (1)", "No Status (1)"],
"columns in the board's order, empty ones left out, the unplaced last"
);
let line = prose.iter().find(|line| line.contains("PVTI_1")).expect("the ready card");
assert!(line.contains("Add the model") && line.contains("Priority: P1"), "{line}");
assert!(!line.contains("Status:"), "the column is the heading, not repeated on the card: {line}");
let wanted = |field: &str, value: &str| vec![(field.to_string(), value.to_string())];
assert!(passes(&board, &ready, &wanted("status", "READY")));
assert!(!passes(&board, &ready, &wanted("Status", "Backlog")));
assert!(!passes(&board, &loose, &wanted("Status", "Ready")));
assert!(passes(&board, &ready, &[wanted("Status", "Ready"), wanted("Priority", "p1")].concat()));
assert!(!passes(&board, &ready, &[wanted("Status", "Ready"), wanted("Priority", "P2")].concat()));
}
#[test]
fn test12409_a_command_called_wrongly_is_refused_before_anything_is_read() {
std::env::set_var("PRJ", "/nonexistent/wsb-github-cli-test");
let wrong: &[&[&str]] = &[
&["reticulate"],
&["project", "reticulate"],
&["project", "show"],
&["project", "items"],
&["project", "items", "1", "--colour", "red"],
&["project", "items", "1", "--where", "Status"],
&["project", "item", "1"],
&["project", "item", "1", "a", "b"],
&["project", "add", "1"],
&["project", "add", "1", "--title", "t", "--url", "https://github.com/o/r/issues/1"],
&["project", "add", "1", "--title", " "],
&["project", "add", "1", "--url", "https://github.com/o/r/issues/1", "--body", "b"],
&["project", "add", "1", "--title", "t", "--body", "b", "--body-file", "f"],
&["project", "add", "1", "--title", "t", "--title", "u"],
&["project", "add", "1", "--title"],
&["project", "add", "1", "--title", "t", "--set", "Status"],
&["project", "set", "1", "card"],
&["project", "set", "1", "card", "Status"],
&["project", "clear", "1", "card"],
&["project", "move", "1", "card"],
&["project", "move", "1", "card", "--top", "--after", "other"],
&["project", "edit", "1", "card"],
&["project", "edit", "1", "card", "--title", ""],
&["project", "archive", "1"],
&["project", "remove", "1"],
&["project", "remove", "1", "a", "b"],
&["project", "column"],
&["project", "column", "rename", "1", "x"],
&["project", "column", "add", "1"],
&["boards", "add"],
&["boards", "add", "machinefabric"],
&["issues", "--state", "sometimes"],
&["sync", "now"],
];
for words in wrong {
assert_eq!(run(args(words)), 2, "{words:?} is a mistake at the command line");
}
assert_eq!(run(args(&["help"])), 0);
assert_eq!(run(args(&["--help"])), 0);
assert_eq!(run(Vec::new()), 2);
assert_eq!(run(args(&["project", "show", "1"])), 1);
let read = Arguments::read(
&args(&["1", "--set", "a=1", "card", "--set", "b=2", "--project", "p", "--top"]),
&["--set"],
&["--top"],
)
.unwrap();
assert_eq!(read.positionals, vec!["1", "card"]);
assert_eq!(read.all("--set"), ["a=1", "b=2"]);
assert_eq!(read.value("--project").unwrap(), Some("p"));
assert!(read.switch("--top"));
assert!(matches!(read.value("--set"), Err(Refusal::Usage(_))));
assert!(matches!(
Arguments::read(&args(&["--titel", "x"]), &["--title"], &[]),
Err(Refusal::Usage(_))
));
for verb in ["sync", "status", "boards", "issues", "project show", "project items", "project item",
"project add", "project set", "project clear", "project move", "project edit",
"project archive", "project unarchive", "project remove", "project column add", "project column remove"]
{
assert!(USAGE.contains(&format!(" {verb}")) || USAGE.contains(&format!(", {verb}")),
"the usage does not name '{verb}'");
}
assert!(
USAGE.contains("project_board") && USAGE.contains("project_item_removed"),
"the usage must say what a --jsonl reader is given"
);
}
#[test]
fn a_calendar_date_is_a_real_day() {
assert!(is_calendar_date("2028-02-29"));
assert!(!is_calendar_date("2027-02-29"));
assert!(!is_calendar_date("2026-00-10"));
assert!(!is_calendar_date("26-01-01"));
}