use std::path::PathBuf;
use std::process::Output;
use serde_json::{Value, json};
use crate::common::{Sandbox, stderr, stdout};
use crate::fixtures::{GitHubBoardFields, document, github_projects_with_board};
struct Setup {
sandbox: Sandbox,
board: GitHubBoardFields,
root: PathBuf,
}
impl Setup {
fn new(status_mapping: Value) -> Self {
let sandbox = Sandbox::new();
let root = sandbox.subdirectory("plans");
std::fs::create_dir_all(root.join("projects")).expect("the project folder");
std::fs::create_dir_all(root.join("tasks")).expect("the task folder");
std::fs::write(
root.join("projects/P.md"),
"---\ntitle: A plan\nstatus: Doing\n---\nthe plan\n",
)
.expect("the project");
std::fs::write(
root.join("tasks/A.md"),
"---\ntitle: A step\nstatus: Todo\n---\nthe step\n",
)
.expect("the task");
std::fs::write(root.join("body.md"), "a new task\n").expect("a body file");
let (mut config, board) = github_projects_with_board(&sandbox);
config["status_mapping"] = status_mapping;
sandbox.project_document(&document(&json!({
"plans": {"plugin":"local-md","config":{
"root": root,
"status_mapping": {"Todo":"todo","Doing":"in-progress","Queued":"queued"}}},
"board": {"plugin":"github-projects","config":config}
})));
Self {
sandbox,
board,
root,
}
}
fn run(&self, arguments: &[&str]) -> Output {
self.sandbox
.command()
.args(arguments)
.assert()
.get_output()
.clone()
}
fn json(&self, arguments: &[&str]) -> Value {
let output = self.run(arguments);
assert_eq!(
output.status.code(),
Some(0),
"`onetaskgraph {}` failed\n{}",
arguments.join(" "),
stderr(&output)
);
serde_json::from_str(&stdout(&output)).expect("the command emits JSON")
}
fn mutations(&self) -> Vec<(String, Value)> {
self.board
.served()
.into_iter()
.filter(|(query, _)| query.trim_start().starts_with("mutation"))
.collect()
}
fn ids(&self, arguments: &[&str]) -> Vec<String> {
self.json(arguments)["items"]
.as_array()
.expect("a page")
.iter()
.map(|item| item["id"].as_str().expect("a qualified id").to_owned())
.collect()
}
}
#[test]
fn a_status_its_kind_has_no_option_for_is_refused_on_every_verb_before_any_mutation() {
let cases: Vec<(Value, Vec<&str>, &str, &str, &str)> = vec![
(
json!({"todo":"Todo","in-progress":"Doing"}),
vec![
"task",
"create",
"board",
"--project",
"P-1",
"--title",
"New",
"--status",
"draft",
"--body-file",
"BODY",
],
"task",
"draft",
"does not name draft",
),
(
json!({"todo":"Todo","in-progress":"Doing","backlog":null}),
vec!["task", "status", "set", "board:T-1", "backlog"],
"task",
"backlog",
"sets backlog to null",
),
(
json!({"todo":"Todo","in-progress":"Doing","cancelled":{"project":"Cancelled"}}),
vec!["task", "update", "board:T-1", "--status", "cancelled"],
"task",
"cancelled",
"names cancelled for the other kind only",
),
(
json!({"todo":{"project":"Todo"},"in-progress":"Doing"}),
vec!["task", "copy", "plans:A", "--to", "board"],
"task",
"todo",
"names todo for the other kind only",
),
(
json!({"todo":"Todo","in-progress":{"task":"Doing"}}),
vec!["project", "copy", "plans:P", "--to", "board", "--no-tasks"],
"project",
"in-progress",
"names in-progress for the other kind only",
),
(
json!({"todo":{"task":"Ready","project":"Todo"},"in-progress":"Doing"}),
vec![
"task",
"create",
"board",
"--project",
"P-1",
"--title",
"New",
"--status",
"todo",
"--body-file",
"BODY",
],
"task",
"todo",
"\"Ready\"",
),
(
json!({"todo":{"task":"Ready","project":"Todo"},"in-progress":"Doing"}),
vec!["task", "status", "set", "board:T-3", "todo"],
"task",
"todo",
"\"Ready\"",
),
(
json!({"todo":"Todo","in-progress":{"task":"Doing","project":"Active"}}),
vec!["project", "copy", "plans:P", "--to", "board", "--no-tasks"],
"project",
"in-progress",
"\"Active\"",
),
];
for (mapping, command, kind, category, why) in cases {
let setup = Setup::new(mapping.clone());
let body = setup.root.join("body.md");
let command: Vec<&str> = command
.into_iter()
.map(|argument| {
if argument == "BODY" {
body.to_str().expect("a UTF-8 path")
} else {
argument
}
})
.collect();
let output = setup.run(&command);
let said = stderr(&output);
assert_ne!(
output.status.code(),
Some(0),
"{mapping}: `onetaskgraph {}` was meant to be refused\n{}",
command.join(" "),
stdout(&output)
);
for part in [
"board",
&format!("{kind} status"),
category,
&format!("status_mapping.{category}.{kind}"),
why,
] {
assert!(
said.contains(part),
"{mapping}: `onetaskgraph {}` was refused without naming {part:?}:\n{said}",
command.join(" ")
);
}
assert_eq!(
setup.mutations(),
Vec::<(String, Value)>::new(),
"{mapping}: `onetaskgraph {}` sent a mutation before refusing",
command.join(" ")
);
}
}
#[test]
fn a_project_copy_over_an_item_at_an_option_the_board_lacks_sends_no_mutation_or_restore() {
let setup = Setup::new(json!({
"todo": "Todo",
"in-progress": {"task": "Doing", "project": "Active"},
}));
let project = setup.root.join("projects/P.md");
std::fs::write(
&project,
"---\ntitle: A plan\nstatus: Todo\n---\nthe plan\n",
)
.expect("the project at todo");
let copied = setup.json(&[
"--json",
"project",
"copy",
"plans:P",
"--to",
"board",
"--no-tasks",
]);
let landed = copied["items"][0]["destination"]
.as_str()
.unwrap_or_else(|| panic!("a destination: {copied:#}"))
.to_owned();
let held = std::fs::read_to_string(&project).expect("the project as the copy left it");
std::fs::write(&project, held.replace("status: Todo", "status: Doing"))
.expect("the project in progress");
let from = setup.mutations().len();
let output = setup.run(&["project", "copy", "plans:P", "--to", "board", "--no-tasks"]);
assert_ne!(output.status.code(), Some(0), "{}", stdout(&output));
let said = stderr(&output);
for part in [
"board",
"project status",
"in-progress",
"status_mapping.in-progress.project",
"\"Active\"",
] {
assert!(
said.contains(part),
"refused without naming {part:?}:\n{said}"
);
}
assert_eq!(
setup.mutations()[from..].to_vec(),
Vec::<(String, Value)>::new(),
"an update refused before any write sends no write and no restore"
);
let shown = setup.json(&["--json", "project", "show", &landed]);
assert_eq!(
shown["items"][0]["item"]["status"]["name"], "Todo",
"{shown:#}"
);
}
#[test]
fn a_copy_refused_after_it_overwrote_a_board_project_puts_that_project_back() {
let setup = Setup::new(json!({
"todo": {"task": "Ready", "project": "Todo"},
"in-progress": "Doing",
}));
let project = setup.root.join("projects/P.md");
std::fs::write(
&project,
"---\ntitle: A plan\nstatus: Todo\n---\nthe plan\n",
)
.expect("the project at todo");
let copied = setup.json(&[
"--json",
"project",
"copy",
"plans:P",
"--to",
"board",
"--no-tasks",
]);
let landed = copied["items"][0]["destination"]
.as_str()
.unwrap_or_else(|| panic!("a destination: {copied:#}"))
.to_owned();
let held = std::fs::read_to_string(&project).expect("the project as the copy left it");
std::fs::write(
&project,
held.replace("title: A plan", "title: A renamed plan"),
)
.expect("the project retitled");
std::fs::write(
setup.root.join("tasks/A.md"),
"---\ntitle: A step\nstatus: Todo\nproject: P\n---\nthe step\n",
)
.expect("the task under the project");
let from = setup.mutations().len();
let output = setup.run(&["project", "copy", "plans:P", "--to", "board"]);
assert_ne!(output.status.code(), Some(0), "{}", stdout(&output));
let said = stderr(&output);
assert!(
said.contains("status_mapping.todo.task") && said.contains("\"Ready\""),
"{said}"
);
let titles = setup.mutations()[from..]
.iter()
.filter_map(|(_, variables)| variables["input"]["title"].as_str().map(str::to_owned))
.collect::<Vec<_>>();
assert_eq!(
titles,
["A renamed plan", "A plan"],
"the project's write, then its restore — and no write of the refused task"
);
let shown = setup.json(&["--json", "project", "show", &landed]);
assert_eq!(shown["items"][0]["item"]["title"], "A plan", "{shown:#}");
}
#[test]
fn each_kind_is_written_read_and_narrowed_through_its_own_half_of_the_mapping() {
let setup = Setup::new(json!({
"todo": {"task": "Todo"},
"queued": {"project": "Todo"},
"in-progress": {"project": "Doing"},
}));
let statuses = |list: &[&str]| -> Vec<(String, Value)> {
setup.json(list)["items"]
.as_array()
.expect("a page")
.iter()
.map(|item| {
(
item["id"].as_str().expect("an id").to_owned(),
item["item"]["status"].clone(),
)
})
.collect()
};
assert_eq!(
statuses(&["--json", "task", "list", "--source", "board"]),
[
(
"board:T-1".to_owned(),
json!({"category": "todo", "name": "Todo"})
),
(
"board:T-2".to_owned(),
json!({"category": "done", "name": "Shipped"})
),
(
"board:T-3".to_owned(),
json!({"category": "todo", "name": "Todo"})
),
(
"board:T-4".to_owned(),
json!({"category": "unknown", "name": "Doing"})
),
]
);
assert_eq!(
statuses(&["--json", "project", "list", "--source", "board"]),
[
(
"board:P-1".to_owned(),
json!({"category": "in-progress", "name": "Doing"})
),
(
"board:P-2".to_owned(),
json!({"category": "queued", "name": "Todo"})
),
]
);
for (status, tasks, projects) in [
("todo", vec!["board:T-1", "board:T-3"], vec![]),
("queued", vec![], vec!["board:P-2"]),
("in-progress", vec![], vec!["board:P-1"]),
("unknown", vec!["board:T-4"], vec![]),
("done", vec!["board:T-2"], vec![]),
] {
assert_eq!(
setup.ids(&[
"--json", "task", "list", "--source", "board", "--status", status
]),
tasks,
"tasks at {status}"
);
assert_eq!(
setup.ids(&[
"--json", "project", "list", "--source", "board", "--status", status
]),
projects,
"projects at {status}"
);
}
let set = setup.json(&["--json", "task", "status", "set", "board:T-4", "todo"]);
assert_eq!(
set["status"],
json!({"category": "todo", "name": "Todo"}),
"{set}"
);
std::fs::write(
setup.root.join("projects/P.md"),
"---\ntitle: A plan\nstatus: Queued\n---\nthe plan\n",
)
.expect("the project");
setup.json(&[
"--json",
"project",
"copy",
"plans:P",
"--to",
"board",
"--no-tasks",
]);
let queued = statuses(&[
"--json", "project", "list", "--source", "board", "--status", "queued",
]);
assert_eq!(queued.len(), 2, "P-2 and the copy: {queued:?}");
assert!(
queued
.iter()
.all(|(_, status)| *status == json!({"category": "queued", "name": "Todo"})),
"{queued:?}"
);
}
#[test]
fn sources_fields_names_each_kinds_missing_options_and_apply_adds_both_keeping_every_id() {
let setup = Setup::new(json!({
"todo": "Todo", "in-progress": "Doing",
"draft": {"task": "Idea"},
"queued": {"task": "Queued", "project": "Waiting"},
"done": {"project": "Shipped"},
}));
let before = setup.board.status_options();
let planned = setup.json(&["--json", "sources", "fields", "board"]);
let status = &planned["fields"][0];
assert_eq!(status["field"], "Status");
assert_eq!(status["missing"], json!(["Idea", "Waiting"]));
assert_eq!(
status["kinds"],
json!([{"kind": "task", "missing": ["Idea"]},
{"kind": "project", "missing": ["Waiting"]}])
);
assert_eq!(status["outcome"], "planned");
assert!(
planned["fields"][1].get("kinds").is_none(),
"Priority carries no kinds: {}",
planned["fields"][1]
);
let text = setup.run(&["sources", "fields", "board"]);
assert_eq!(
stdout(&text),
"board: missing configured Status options: task: Idea; project: Waiting\n\
board: missing configured Priority options: none\n"
);
assert!(setup.mutations().is_empty(), "a plan writes nothing");
let applied = setup.run(&["sources", "fields", "board", "--apply"]);
assert_eq!(applied.status.code(), Some(0), "{}", stderr(&applied));
assert_eq!(
stdout(&applied),
"board: added and verified Status options: task: Idea; project: Waiting\n\
board: missing configured Priority options: none\n"
);
let sent = setup.mutations();
assert_eq!(sent.len(), 1, "one whole-list update: {sent:?}");
let options = sent[0].1["input"]["singleSelectOptions"]
.as_array()
.expect("the whole option list")
.clone();
for old in &before {
assert!(
options.contains(old),
"every existing option goes back with its id: {old} in {options:?}"
);
}
let after = setup.board.status_options();
for old in &before {
assert!(after.contains(old), "{old} kept its id: {after:?}");
}
for added in ["Idea", "Waiting"] {
assert!(
after.iter().any(|option| option["name"] == added),
"{added} was added: {after:?}"
);
}
let again = setup.json(&["--json", "sources", "fields", "board"]);
assert_eq!(again["fields"][0]["missing"], json!([]));
assert!(again["fields"][0].get("kinds").is_none(), "{again}");
let set = setup.json(&["--json", "task", "status", "set", "board:T-3", "draft"]);
assert_eq!(
set["status"],
json!({"category": "draft", "name": "Idea"}),
"{set}"
);
}