use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Utc};
use crate::collect::run::{FailureKind, RunFailure};
use crate::collect::tracker::{OpenFailure, Tracker, Trackers};
use crate::config::{Config, Project};
use crate::model::edges::{self, Relations};
use crate::model::join;
use crate::model::snapshot::{Readiness, TrackerFailure, TrackerState};
use crate::model::tree::{Assembled, Nesting};
use crate::model::types::{Bead, Pane, Unreadable};
pub(super) struct ProjectWork {
pub(super) readiness: Readiness,
pub(super) relations: BTreeMap<String, Relations>,
pub(super) roots: Vec<(String, Result<Assembled, RootUnread>)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RootUnread {
Tracker(TrackerFailure),
NotFound,
}
impl From<RootUnread> for TrackerState {
fn from(why: RootUnread) -> Self {
match why {
RootUnread::Tracker(failure) => TrackerState::Unreachable(failure),
RootUnread::NotFound => TrackerState::RootNotFound,
}
}
}
#[derive(Clone)]
pub(super) struct ReadAt {
project: Project,
working_root: String,
named: BTreeSet<String>,
roots: BTreeSet<String>,
speaks_until: Option<DateTime<Utc>>,
}
impl ReadAt {
fn still_speaks_for(
&self,
project: &Project,
working_root: &str,
named: &BTreeSet<String>,
roots: &BTreeSet<String>,
now: DateTime<Utc>,
) -> bool {
self.project == *project
&& self.working_root == working_root
&& self.named == *named
&& self.roots == *roots
&& self.speaks_until.is_none_or(|until| now < until)
}
}
fn speaks_until(beads: &[Bead], read_at: DateTime<Utc>) -> Option<DateTime<Utc>> {
beads
.iter()
.filter_map(|bead| bead.defer_until)
.filter(|until| *until > read_at)
.min()
}
pub(super) enum Refresh {
Unchanged,
Read {
at: Option<Box<ReadAt>>,
work: Box<ProjectWork>,
},
}
pub(super) fn refresh_project(
trackers: &dyn Trackers,
project: &Project,
cfg: &Config,
panes: &[Pane],
standing: Option<&ReadAt>,
now: DateTime<Utc>,
) -> Result<Refresh, OpenFailure> {
let tracker = trackers.of(project)?;
let probed = tracker.fingerprint().and_then(Result::ok);
let named: BTreeSet<String> = panes_naming_a_bead_here(panes, project, cfg)
.map(str::to_string)
.collect();
let roots = roots_named(cfg, project);
if let (Some(working_root), Some(standing)) = (probed.as_deref(), standing) {
if standing.still_speaks_for(project, working_root, &named, &roots, now) {
return Ok(Refresh::Unchanged);
}
}
let (work, beads) = read_project(tracker.as_ref(), project, cfg, panes)?;
let at = probed.map(|working_root| {
Box::new(ReadAt {
project: project.clone(),
working_root,
named,
roots,
speaks_until: speaks_until(&beads, now),
})
});
Ok(Refresh::Read {
at,
work: Box::new(work),
})
}
fn read_project(
tracker: &dyn Tracker,
project: &Project,
cfg: &Config,
panes: &[Pane],
) -> Result<(ProjectWork, Vec<Bead>), RunFailure> {
let beads = tracker.all()?;
let readiness = Readiness {
ready: tracker.ready()?,
blocked_by: tracker.blocked()?,
};
let parents: BTreeMap<&str, Option<&str>> = beads
.iter()
.map(|bead| (bead.id.as_str(), bead.parent.as_deref()))
.collect();
let nesting = Nesting::of(&beads);
let mut ancestors: BTreeMap<String, Climbed> = BTreeMap::new();
let mut climbed: BTreeSet<Climbed> = BTreeSet::new();
for bead in unfinished(&beads) {
climbed.extend(root_of(bead, &parents, &mut ancestors));
}
for named in panes_naming_a_bead_here(panes, project, cfg) {
climbed.extend(root_of(named, &parents, &mut ancestors));
}
let mut roots = roots_named(cfg, project);
roots.extend(
climbed
.into_iter()
.flat_map(|end| drawn_from(end, &nesting)),
);
let mut read: Vec<(String, Result<Assembled, RootUnread>)> = roots
.into_iter()
.map(|root| {
let read = nesting.assemble(&root).map_err(|_| RootUnread::NotFound);
(root, read)
})
.collect();
read.extend(what_no_root_reached(&nesting, &read));
read.sort_by(|(one, _), (two, _)| one.cmp(two));
Ok((
ProjectWork {
readiness,
relations: edges::relations(&beads),
roots: read,
},
beads,
))
}
fn what_no_root_reached(
nesting: &Nesting,
read: &[(String, Result<Assembled, RootUnread>)],
) -> Vec<(String, Result<Assembled, RootUnread>)> {
let drawn: BTreeSet<&str> = read
.iter()
.filter_map(|(_, read)| read.as_ref().ok())
.flat_map(|assembled| assembled.beads.iter().map(|bead| bead.id.as_str()))
.collect();
let tops: BTreeSet<String> = nesting
.adrift()
.into_iter()
.filter(|id| !drawn.contains(id.as_str()))
.flat_map(|id| nesting.top_of(&id))
.collect();
tops.into_iter()
.map(|id| {
let read = nesting.assemble(&id).map_err(|missing| {
RootUnread::Tracker(TrackerFailure::Parse(Unreadable {
read: "list".to_string(),
cause: missing.to_string(),
}))
});
(id, read)
})
.collect()
}
fn unfinished(beads: &[Bead]) -> impl Iterator<Item = &str> {
beads
.iter()
.filter(|bead| !bead.status.is_closed())
.map(|bead| bead.id.as_str())
}
fn roots_named(cfg: &Config, project: &Project) -> BTreeSet<String> {
cfg.roots
.explicit
.get(&project.name)
.into_iter()
.flatten()
.cloned()
.collect()
}
fn panes_naming_a_bead_here<'a>(
panes: &'a [Pane],
project: &'a Project,
cfg: &'a Config,
) -> impl Iterator<Item = &'a str> {
panes
.iter()
.filter(|pane| {
join::project_of(pane, &cfg.projects).is_some_and(|p| p.name == project.name)
})
.filter_map(|pane| pane.display_agent.as_deref())
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Climbed {
Rootless(String),
Stopped(String),
}
fn drawn_from(climbed: Climbed, nesting: &Nesting) -> Vec<String> {
match climbed {
Climbed::Stopped(id) => vec![id],
Climbed::Rootless(id) => nesting.top_of(&id),
}
}
fn root_of<'a>(
id: &'a str,
parents: &BTreeMap<&'a str, Option<&'a str>>,
ancestors: &mut BTreeMap<String, Climbed>,
) -> Option<Climbed> {
if !parents.contains_key(id) {
return None;
}
let mut climbed: Vec<&str> = Vec::new();
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut current = id;
let end = loop {
if let Some(known) = ancestors.get(current) {
break known.clone();
}
if !seen.insert(current) {
break Climbed::Stopped(current.to_string());
}
climbed.push(current);
match parents[current] {
Some(parent) if parents.contains_key(parent) => current = parent,
Some(_) => break Climbed::Stopped(current.to_string()),
None => break Climbed::Rootless(current.to_string()),
}
};
for climbed in climbed {
ancestors.insert(climbed.to_string(), end.clone());
}
Some(end)
}
pub(super) fn open_failure(failure: &OpenFailure) -> TrackerFailure {
match failure {
OpenFailure::NoEnvironment => TrackerFailure::NoEnvironment,
OpenFailure::NoCredential => TrackerFailure::NoCredential,
OpenFailure::Refused(refusal) => tracker_failure(refusal),
}
}
pub(super) fn tracker_failure(failure: &RunFailure) -> TrackerFailure {
match failure.kind {
FailureKind::Auth => TrackerFailure::Auth,
FailureKind::Unavailable
| FailureKind::Gone
| FailureKind::Busy
| FailureKind::Unsupported => TrackerFailure::Unavailable,
FailureKind::NotInstalled => TrackerFailure::NotInstalled,
FailureKind::Unstartable => TrackerFailure::Unstartable,
FailureKind::InstalledUnstartable => TrackerFailure::InstalledUnstartable,
FailureKind::Parse => TrackerFailure::Parse(failure.unreadable.clone().unwrap_or_default()),
FailureKind::UnknownFlag => TrackerFailure::UnknownFlag,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::fixtures::*;
use crate::app::run;
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use crate::collect::agents::testing::{named, pane, Fake as Provider};
use crate::collect::run::{Env, RealRunner, Runner};
use crate::collect::tracker::testing::{Asked, Fake, Fakes};
use crate::model::snapshot::{FailedProject, Filter, Snapshot, TrackerState, Tree};
use crate::model::tree::nestings_on_this_thread;
use crate::model::types::PaneStatus;
use pretty_assertions::assert_eq;
const MAST_TREE: &str = r#"[
{"id":"orb-4","title":"survey the mast","status":"closed",
"priority":2,"issue_type":"task"}
]"#;
const CLOSED_OVER_OPEN_WORK: &str = r#"[
{"id":"orb-7","title":"lift the ground station","status":"closed",
"priority":1,"issue_type":"epic"},
{"id":"orb-7.1","title":"re-point the dish","status":"in_progress","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task",
"metadata":{"agent_pane":"w:p1"}},
{"id":"orb-7.2","title":"lay the feeder cable","status":"open","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task"}
]"#;
const CHAIN_OF_PARENTLESS: &str = r#"[
{"id":"orb-c1","title":"the hub nothing depends on","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-c2","type":"blocks"}]},
{"id":"orb-c2","title":"the second link","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-c3","type":"blocks"}]},
{"id":"orb-c3","title":"the third link","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-c4","type":"blocks"}]},
{"id":"orb-c4","title":"the fourth link","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-c5","type":"blocks"}]},
{"id":"orb-c5","title":"the bead everything waits on","status":"open",
"priority":2,"issue_type":"task"}
]"#;
fn rooted_at<'a>(snap: &'a Snapshot, root: &str) -> &'a Tree {
snap.trees
.iter()
.find(|t| t.root == root)
.unwrap_or_else(|| panic!("{root} is drawn"))
}
fn drawn(snap: &Snapshot, id: &str) -> bool {
snap.trees
.iter()
.any(|tree| tree.beads.iter().any(|bead| bead.id == id))
}
fn drawings(snap: &Snapshot, id: &str) -> usize {
snap.trees
.iter()
.filter(|tree| tree.beads.iter().any(|bead| bead.id == id))
.count()
}
#[test]
fn a_changed_refresh_asks_the_tracker_for_its_beads_once() {
let trackers = orbital();
run(&one_project(), &panes(), &trackers, Filter::All, now());
let listings = trackers
.tracker("orbital")
.asked()
.into_iter()
.filter(|question| *question == Asked::All)
.count();
assert_eq!(listings, 1);
}
#[test]
fn one_read_nests_the_tracker_once_however_many_roots_it_draws() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[roots.explicit]
orbital = ["orb-4"]
"#
))
.expect("the config parses");
let lost = r#"[{"id":"orb-3","title":"its parent was deleted","status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let tracker = orbital_tracker().also(beads(MAST_TREE)).also(beads(lost));
let before = nestings_on_this_thread();
let (work, _) = read_project(&tracker, &cfg.projects[0], &cfg, &[])
.expect("the tracker answers every call");
let roots: Vec<&str> = work.roots.iter().map(|(root, _)| root.as_str()).collect();
assert_eq!(roots, vec!["orb-3", "orb-4", "orb-7"]);
assert_eq!(nestings_on_this_thread() - before, 1);
}
#[test]
fn a_discovered_bead_is_drawn_as_the_tree_it_hangs_under() {
let snap = run(
&one_project(),
&panes(),
&orbital(),
Filter::LiveAgents,
now(),
);
assert_eq!(snap.trees.len(), 1);
assert_eq!(snap.trees[0].root, "orb-7");
assert_eq!(snap.trees[0].title, "lift the ground station");
assert_eq!(snap.trees[0].beads.len(), 3);
}
#[test]
fn discovery_names_every_unfinished_bead_and_wisp_and_nothing_closed() {
let listing = r#"[
{"id":"orb-7","title":"lift the ground station","status":"closed",
"priority":1,"issue_type":"epic"},
{"id":"orb-7.1","title":"re-point the dish","status":"open","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task"},
{"id":"orb-8","title":"decommission the old mast","status":"closed",
"priority":1,"issue_type":"epic"},
{"id":"orb-8.1","title":"cut the guy lines","status":"closed","parent":"orb-8",
"dependencies":[{"depends_on_id":"orb-8","type":"parent-child"}],
"priority":2,"issue_type":"task",
"metadata":{"working_topic":"orbital/v1-orb-8.1"}},
{"id":"orb-9","title":"wait for the permit","status":"deferred",
"priority":3,"issue_type":"task"}
]"#;
let wisps = r#"[
{"id":"orb-wisp-a1","title":"heartbeat","status":"open",
"priority":2,"issue_type":"task"},
{"id":"orb-wisp-b2","title":"a run that finished","status":"closed",
"priority":2,"issue_type":"molecule"}
]"#;
let trackers = orbital_with(orbital_holding(listing).also(beads(wisps)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
assert!(
snap.failed_projects.is_empty(),
"{:?}",
snap.failed_projects
);
let roots: BTreeSet<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
BTreeSet::from(["orb-7", "orb-9", "orb-wisp-a1"]),
"the open task climbs to its closed epic; the deferred bead and the open wisp are roots of their own"
);
}
#[test]
fn a_bead_whose_parent_the_answer_has_lost_is_top_of_its_own_tree() {
let orphan_bead = r#"[{"id":"orb-7.9","title":"its parent is a digest",
"status":"open","parent":"orb-404",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(orphan_bead)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert!(
snap.failed_projects.is_empty(),
"one bead the read cannot place must not take the tracker down: {:?}",
snap.failed_projects
);
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-7.9"],
"the chain stops at the last bead this read holds, and names no root above it"
);
for tree in &snap.trees {
assert_eq!(
tree.tracker,
TrackerState::Ok,
"{} answered every call, so no tree of its calls it unreadable",
tree.root
);
}
assert_eq!(
rooted_at(&snap, "orb-7").dangling,
Vec::<String>::new(),
"the tree that never reached it does not report it"
);
assert_eq!(
rooted_at(&snap, "orb-7.9").dangling,
vec!["orb-7.9".to_string()],
"its own tree names the work the tracker no longer holds"
);
}
#[test]
fn a_chain_of_parentless_beads_is_drawn_as_one_tree() {
let trackers = orbital_with(orbital_holding(CHAIN_OF_PARENTLESS));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
for link in ["orb-c1", "orb-c2", "orb-c3", "orb-c4", "orb-c5"] {
assert!(drawn(&snap, link), "{link} is on the screen");
}
assert_eq!(
drawings(&snap, "orb-c5"),
1,
"and the deepest is drawn once, rather than under every bead that blocks it"
);
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-c1"],
"the one bead nothing places stands for the whole chain"
);
}
#[test]
fn a_parentless_bead_nothing_places_is_still_a_root() {
let alone = r#"[{"id":"orb-lone","title":"nothing depends on it","status":"open",
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(alone)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
assert!(drawn(&snap, "orb-lone"), "it is on the screen");
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-lone"]);
}
#[test]
fn a_parentless_bead_placed_only_by_a_closed_bead_is_drawn_under_it() {
let under_a_closed_bead = r#"[
{"id":"orb-5","title":"finished, and still standing over work",
"status":"closed","priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-5.1","type":"blocks"}]},
{"id":"orb-5.1","title":"open, with no parent and one blocker",
"status":"open","priority":2,"issue_type":"task"}
]"#;
let trackers = orbital_with(orbital_tracker().also(beads(under_a_closed_bead)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
assert!(drawn(&snap, "orb-5.1"), "the open bead is on the screen");
assert_eq!(drawings(&snap, "orb-5.1"), 1);
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-5"],
"the closed bead that places it is where the tree drawing it starts"
);
}
#[test]
fn a_cycle_of_parentless_beads_is_drawn_once_from_a_stable_top() {
let looping = r#"[
{"id":"orb-l1","title":"blocked by the other","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-l2","type":"blocks"}]},
{"id":"orb-l2","title":"and blocked by the first","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-l1","type":"blocks"}]}
]"#;
let trackers = orbital_with(orbital_tracker().also(beads(looping)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
for bead in ["orb-l1", "orb-l2"] {
assert!(drawn(&snap, bead), "{bead} is on the screen");
assert_eq!(drawings(&snap, bead), 1, "{bead} is drawn in one tree");
}
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-l1"]);
assert_eq!(
rooted_at(&snap, "orb-l1").cycles,
vec!["orb-l1".to_string()],
"and the loop is reported where it was cut"
);
}
#[test]
fn a_wisp_with_no_parent_is_still_a_root_of_its_own() {
let a_run_in_flight = r#"[
{"id":"orb-wisp-a1","title":"a run in flight","status":"open",
"priority":2,"issue_type":"molecule"},
{"id":"orb-wisp-a1.1","title":"its first step","status":"open",
"parent":"orb-wisp-a1",
"dependencies":[{"depends_on_id":"orb-wisp-a1","type":"parent-child"}],
"priority":2,"issue_type":"task"}
]"#;
let trackers = orbital_with(orbital_tracker().also(beads(a_run_in_flight)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-wisp-a1"]);
assert_eq!(
rooted_at(&snap, "orb-wisp-a1")
.beads
.iter()
.map(|n| n.id.as_str())
.collect::<Vec<_>>(),
vec!["orb-wisp-a1", "orb-wisp-a1.1"],
"and its steps hang under it"
);
}
#[test]
fn a_bead_whose_parent_is_lost_stays_a_root_even_where_an_edge_places_it() {
let placed_and_orphaned = r#"[
{"id":"orb-2","title":"blocked by the orphan","status":"open",
"priority":2,"issue_type":"task",
"dependencies":[{"depends_on_id":"orb-7.9","type":"blocks"}]},
{"id":"orb-7.9","title":"its parent is a digest","status":"open",
"parent":"orb-404",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}
]"#;
let trackers = orbital_with(orbital_tracker().also(beads(placed_and_orphaned)));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-2", "orb-7.9"]);
assert_eq!(
rooted_at(&snap, "orb-7.9").dangling,
vec!["orb-7.9".to_string()],
"and the parent the answer lost is still reported"
);
}
#[test]
fn a_root_the_reader_has_just_named_is_drawn_though_the_tracker_has_not_moved() {
let trackers = orbital();
let before = one_project();
let after = one_project_with_a_root_named();
let first = refresh_project(&trackers, &before.projects[0], &before, &[], None, now())
.expect("the tracker answers every call");
let Refresh::Read { at, .. } = first else {
panic!("a project nothing has read is read in full")
};
let refreshed = refresh_project(
&trackers,
&after.projects[0],
&after,
&[],
at.as_deref(),
now(),
)
.expect("the tracker answers every call");
let Refresh::Read { work, .. } = refreshed else {
panic!("the cascade was skipped against a read taken before the reader named the root")
};
let roots: Vec<&str> = work.roots.iter().map(|(root, _)| root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-7.1"],
"and the tree the reader asked for is one of them"
);
}
#[test]
fn a_root_config_names_is_a_root_even_where_an_edge_places_it() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[roots.explicit]
orbital = ["orb-c3"]
"#
))
.expect("the config parses");
let tracker = orbital_holding(CHAIN_OF_PARENTLESS);
let (work, _) = read_project(&tracker, &cfg.projects[0], &cfg, &[])
.expect("the tracker answers every call");
let roots: Vec<&str> = work.roots.iter().map(|(root, _)| root.as_str()).collect();
assert_eq!(roots, vec!["orb-c1", "orb-c3"]);
}
#[test]
fn a_pane_naming_a_buried_bead_draws_it_where_the_tree_puts_it() {
let watching = Provider::holding(vec![named(
pane("w:p1", ORBITAL, PaneStatus::Working),
"orb-c5",
)]);
let trackers = orbital_with(orbital_holding(CHAIN_OF_PARENTLESS));
let snap = run(&one_project(), &watching, &trackers, Filter::All, now());
assert!(drawn(&snap, "orb-c5"), "the bead the pane names is drawn");
assert_eq!(drawings(&snap, "orb-c5"), 1);
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-c1"],
"a pane on a buried bead does not stand it up beside its own tree"
);
}
#[test]
fn a_closed_bead_the_answer_holds_no_way_down_to_is_still_drawn() {
let lost = r#"[{"id":"orb-3","title":"its parent was deleted","status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(lost)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-3"]);
assert_eq!(
rooted_at(&snap, "orb-3").dangling,
vec!["orb-3".to_string()]
);
}
#[test]
fn a_component_nothing_discovered_is_drawn_from_its_top() {
let component = r#"[{"id":"orb-5","title":"the parent it was moved to","status":"closed",
"priority":2,"issue_type":"task"},
{"id":"orb-5.1","title":"moved off a parent that is gone",
"status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"},
{"depends_on_id":"orb-5","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(component)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-5"]);
let component = rooted_at(&snap, "orb-5");
assert_eq!(
component
.beads
.iter()
.map(|n| n.id.as_str())
.collect::<Vec<_>>(),
vec!["orb-5", "orb-5.1"],
"the bead that lost its place is drawn where its surviving edge puts it"
);
assert_eq!(
component.dangling,
vec!["orb-5.1".to_string()],
"and the tree that draws it is the one that reports it"
);
}
#[test]
fn a_component_whose_top_is_a_loop_is_drawn_from_inside_the_loop() {
let looping = r#"[{"id":"orb-9","title":"each other's parent","status":"closed",
"dependencies":[{"depends_on_id":"orb-9b","type":"parent-child"}],
"priority":2,"issue_type":"epic"},
{"id":"orb-9b","title":"and the other way round","status":"closed",
"dependencies":[{"depends_on_id":"orb-9","type":"parent-child"}],
"priority":2,"issue_type":"epic"},
{"id":"orb-9.1","title":"under the loop, and off a parent that is gone",
"status":"closed",
"dependencies":[{"depends_on_id":"orb-9","type":"parent-child"},
{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(looping)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-9"]);
let component = rooted_at(&snap, "orb-9");
assert_eq!(
component
.beads
.iter()
.map(|n| n.id.as_str())
.collect::<Vec<_>>(),
vec!["orb-9", "orb-9.1", "orb-9b"],
"every bead in the component is drawn, loop included"
);
assert_eq!(
component.cycles,
vec!["orb-9".to_string()],
"and the loop is reported where it was cut"
);
assert_eq!(component.dangling, vec!["orb-9.1".to_string()]);
}
#[test]
fn a_loop_over_a_bead_is_drawn_even_where_another_parent_has_a_top() {
let both_ways = r#"[{"id":"orb-6","title":"a top of its own","status":"closed",
"priority":2,"issue_type":"epic"},
{"id":"orb-6c","title":"each other's parent","status":"closed",
"dependencies":[{"depends_on_id":"orb-6d","type":"parent-child"}],
"priority":2,"issue_type":"epic"},
{"id":"orb-6d","title":"and the other way round","status":"closed",
"dependencies":[{"depends_on_id":"orb-6c","type":"parent-child"}],
"priority":2,"issue_type":"epic"},
{"id":"orb-6.1","title":"under both, and off a parent that is gone",
"status":"closed",
"dependencies":[{"depends_on_id":"orb-6","type":"parent-child"},
{"depends_on_id":"orb-6c","type":"parent-child"},
{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(both_ways)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let ids = |root: &str| {
rooted_at(&snap, root)
.beads
.iter()
.map(|n| n.id.to_string())
.collect::<Vec<_>>()
};
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-6", "orb-6c"]);
assert_eq!(ids("orb-6"), vec!["orb-6", "orb-6.1"]);
assert_eq!(
ids("orb-6c"),
vec!["orb-6c", "orb-6.1", "orb-6d"],
"the loop the top could not reach is drawn from inside itself"
);
}
#[test]
fn a_top_another_top_already_draws_is_not_a_root_as_well() {
let under_a_loop = r#"[{"id":"orb-2a","title":"under the loop, off a parent that is gone",
"status":"closed",
"dependencies":[{"depends_on_id":"orb-2c","type":"parent-child"},
{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"},
{"id":"orb-2c","title":"each other's parent","status":"closed",
"dependencies":[{"depends_on_id":"orb-2d","type":"parent-child"}],
"priority":2,"issue_type":"epic"},
{"id":"orb-2d","title":"and the other way round","status":"closed",
"dependencies":[{"depends_on_id":"orb-2c","type":"parent-child"}],
"priority":2,"issue_type":"epic"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(under_a_loop)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-2c"],
"one root draws the whole component, so it is the only one"
);
assert_eq!(
rooted_at(&snap, "orb-2c")
.beads
.iter()
.map(|n| n.id.as_str())
.collect::<Vec<_>>(),
vec!["orb-2c", "orb-2a", "orb-2d"]
);
}
#[test]
fn a_bead_whose_absent_dependency_would_have_nested_nothing_is_not_a_root() {
let unrelated = r#"[{"id":"orb-2","title":"found by work that is gone","status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"discovered-by"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(unrelated)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7"]);
}
#[test]
fn a_bead_whose_absent_dependency_would_have_hung_beneath_it_is_not_a_root() {
let waiting = r#"[{"id":"orb-2","title":"waiting on work that is gone","status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"blocks"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(waiting)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7"]);
}
#[test]
fn a_bead_a_tree_already_draws_is_not_made_a_root_as_well() {
let also_waiting = r#"[{"id":"orb-7.1","title":"re-point the dish","status":"in_progress","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"},
{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task",
"metadata":{"agent_pane":"w:p1"}}]"#;
let trackers = orbital_with(orbital_holding(&ORBITAL_TREE.replace(
r#"{"id":"orb-7.1","title":"re-point the dish","status":"in_progress","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task",
"metadata":{"agent_pane":"w:p1"}}"#,
also_waiting
.trim()
.trim_start_matches('[')
.trim_end_matches(']'),
)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7"],
"one tree draws it, so one tree reports it"
);
assert_eq!(snap.trees[0].dangling, vec!["orb-7.1".to_string()]);
}
#[test]
fn a_bead_under_a_lost_bead_is_drawn_under_it_rather_than_beside_it() {
let lost = r#"[{"id":"orb-3","title":"its parent was deleted","status":"closed",
"dependencies":[{"depends_on_id":"orb-404","type":"parent-child"}],
"priority":2,"issue_type":"task"},
{"id":"orb-3.1","title":"under it, waiting on more","status":"closed",
"dependencies":[{"depends_on_id":"orb-3","type":"parent-child"},
{"depends_on_id":"orb-405","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#;
let trackers = orbital_with(orbital_tracker().also(beads(lost)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7", "orb-3"]);
let lost = rooted_at(&snap, "orb-3");
assert_eq!(
lost.beads.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
vec!["orb-3", "orb-3.1"]
);
assert_eq!(
lost.dangling,
vec!["orb-3".to_string(), "orb-3.1".to_string()]
);
}
#[test]
fn a_closed_parent_over_open_work_costs_the_tracker_no_further_question() {
let trackers = orbital_with(orbital_holding(CLOSED_OVER_OPEN_WORK));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert_eq!(snap.trees[0].root, "orb-7");
assert_eq!(
trackers.tracker("orbital").asked(),
vec![Asked::Fingerprint, Asked::All, Asked::Ready, Asked::Blocked],
"a parent the listing already carries is not asked for again"
);
}
#[test]
fn an_effort_is_drawn_from_the_open_work_under_it_with_nobody_on_it() {
let trackers = orbital_with(orbital_holding(
r#"[{"id":"orb-7","title":"lift the ground station","status":"open",
"priority":1,"issue_type":"epic"},
{"id":"orb-7.2","title":"lay the feeder cable","status":"open","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#,
));
let snap = run(&one_project(), &no_panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7"]);
}
#[test]
fn a_parent_chain_that_loops_stops_where_it_repeats() {
let trackers = orbital_with(orbital_holding(
r#"[{"id":"orb-7","title":"lift the ground station","status":"closed","parent":"orb-7.1",
"priority":1,"issue_type":"epic"},
{"id":"orb-7.1","title":"re-point the dish","status":"in_progress","parent":"orb-7",
"priority":2,"issue_type":"task"}]"#,
));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert_eq!(snap.trees.len(), 1);
assert_eq!(snap.trees[0].root, "orb-7.1");
}
#[test]
fn a_root_named_in_config_joins_the_discovered_ones_without_duplicating_them() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[roots.explicit]
orbital = ["orb-7", "orb-4"]
"#
))
.expect("the config parses");
let trackers = orbital_with(orbital_tracker().also(beads(MAST_TREE)));
let snap = run(&cfg, &panes(), &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-4"],
"the root config and discovery both name is drawn once"
);
}
#[test]
fn a_root_named_in_config_is_read_only_from_the_project_it_is_named_under() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[[projects]]
name = "ferry"
path = "{FERRY}"
[roots.explicit]
orbital = ["orb-4"]
"#
))
.expect("the config parses");
let trackers = Fakes::default()
.with("orbital", colliding_tracker().also(beads(MAST_TREE)))
.with("ferry", colliding_tracker());
let snap = run(&cfg, &no_panes(), &trackers, Filter::All, now());
let roots: Vec<(&str, &str)> = snap
.trees
.iter()
.map(|t| (t.project.as_str(), t.root.as_str()))
.collect();
assert_eq!(
roots,
vec![("orbital", "x-1"), ("orbital", "orb-4"), ("ferry", "x-1")],
"ferry draws no tree for a root orbital was given"
);
}
const A_CAPTURED_ANSWER: &str = include_str!("../../tests/fixtures/bd_list.json");
#[test]
fn a_root_named_in_config_that_the_answer_does_not_hold_is_not_an_unreadable_tracker() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[roots.explicit]
orbital = ["bdi-404"]
"#
))
.expect("the config parses");
let trackers = orbital_with(Fake::holding(beads(A_CAPTURED_ANSWER)));
let snap = run(&cfg, &panes(), &trackers, Filter::All, now());
assert!(
snap.failed_projects.is_empty(),
"the project's own tracker answered: {:?}",
snap.failed_projects
);
assert_eq!(
rooted_at(&snap, "bdi-404").tracker,
TrackerState::RootNotFound
);
assert!(
snap.trees.iter().all(|tree| !matches!(
tree.tracker,
TrackerState::Unreachable(TrackerFailure::Parse(_))
)),
"no tree blames the tracker's answer: {:?}",
snap.trees
.iter()
.map(|tree| (tree.root.as_str(), tree.tracker.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn a_bead_named_only_by_a_live_pane_becomes_a_root() {
let panes = Provider::holding(vec![
pane("w:p1", ORBITAL, PaneStatus::Working),
named(pane("w:p4", ORBITAL, PaneStatus::Working), "orb-4"),
]);
let trackers = orbital_with(orbital_tracker().also(beads(MAST_TREE)));
let snap = run(&one_project(), &panes, &trackers, Filter::LiveAgents, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7", "orb-4"],
"the pane's bead joins the roots bd's own statuses found"
);
assert!(snap.hidden_trees.is_empty());
let of_the_pane = snap
.trees
.iter()
.find(|t| t.root == "orb-4")
.expect("the pane's bead roots a tree");
assert!(node(of_the_pane, "orb-4").agent.is_some());
}
#[test]
fn a_pane_labelled_with_something_that_is_not_a_bead_costs_the_project_nothing() {
let panes = Provider::holding(vec![named(
pane("w:p4", ORBITAL, PaneStatus::Working),
"reviewing the docs",
)]);
let snap = run(&one_project(), &panes, &orbital(), Filter::All, now());
assert!(
snap.failed_projects.is_empty(),
"a mislabelled pane is not a tracker outage"
);
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7"], "rules 1 to 3 are untouched");
let loose: Vec<&str> = snap
.unattributed
.iter()
.map(|p| p.pane.id.as_str())
.collect();
assert_eq!(loose, vec!["w:p4"], "the pane is reported, not dropped");
}
#[test]
fn a_pane_naming_a_bead_inside_a_tree_contributes_that_tree_not_the_bead() {
let panes = Provider::holding(vec![named(
pane("w:p4", ORBITAL, PaneStatus::Working),
"orb-7.3",
)]);
let trackers = orbital_with(orbital_tracker().also(beads(
r#"[{"id":"orb-7.3","title":"written up","status":"closed","parent":"orb-7",
"dependencies":[{"depends_on_id":"orb-7","type":"parent-child"}],
"priority":2,"issue_type":"task"}]"#,
)));
let snap = run(&one_project(), &panes, &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(
roots,
vec!["orb-7"],
"climbed to its root, and deduped there"
);
}
#[test]
fn a_pane_contributes_its_root_only_to_the_project_it_sits_in() {
let panes = Provider::holding(vec![named(
pane("w:p4", ORBITAL, PaneStatus::Working),
"orb-4",
)]);
let trackers = Fakes::default()
.with("orbital", colliding_tracker().also(beads(MAST_TREE)))
.with("ferry", colliding_tracker().also(beads(MAST_TREE)));
let snap = run(&two_projects(), &panes, &trackers, Filter::All, now());
let roots: Vec<(&str, &str)> = snap
.trees
.iter()
.map(|t| (t.project.as_str(), t.root.as_str()))
.collect();
assert_eq!(
roots,
vec![("orbital", "orb-4"), ("orbital", "x-1"), ("ferry", "x-1")],
"ferry draws no tree for a bead no pane of its own named"
);
}
#[test]
fn a_pane_under_no_configured_project_contributes_no_root() {
let panes = Provider::holding(vec![named(
pane("w:p4", "/srv/elsewhere", PaneStatus::Working),
"orb-4",
)]);
let trackers = orbital_with(orbital_tracker().also(beads(MAST_TREE)));
let snap = run(&one_project(), &panes, &trackers, Filter::All, now());
let roots: Vec<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, vec!["orb-7"]);
}
#[test]
fn readiness_and_blockers_come_from_the_tracker_rather_than_from_status() {
let snap = run(
&one_project(),
&panes(),
&orbital(),
Filter::LiveAgents,
now(),
);
let tree = &snap.trees[0];
assert!(node(tree, "orb-7.2").ready, "the tracker named it ready");
assert!(!node(tree, "orb-7.1").ready);
assert_eq!(
node(tree, "orb-7.1").blocked_by,
vec!["orb-9".to_string()],
"a blocker outside the tree still reaches the node"
);
assert!(node(tree, "orb-7.2").blocked_by.is_empty());
}
#[test]
fn a_project_whose_listing_fails_is_named_not_dropped() {
let trackers =
orbital_with(orbital_tracker().failing(Asked::All, failing(FailureKind::Auth)));
let snap = run(
&one_project(),
&panes(),
&trackers,
Filter::LiveAgents,
now(),
);
assert!(snap.trees.is_empty());
assert_eq!(
snap.failed_projects,
vec![FailedProject {
project: "orbital".to_string(),
tracker: TrackerFailure::Auth,
}]
);
}
#[test]
fn a_spawn_no_search_could_reach_does_not_tell_the_reader_bd_is_installed() {
let locked = an_unsearchable_directory("no-claim-to-make");
let mut env = Env::new();
env.insert("PATH".to_string(), locked.display().to_string());
let failure = RealRunner
.run("bdi-no-such-program", &[], None, &env)
.expect_err("the one directory on PATH cannot be searched");
let readable_again = std::fs::set_permissions(&locked, Permissions::from_mode(0o755));
let said = crate::view::phrase::tracker_failure(&tracker_failure(&failure));
readable_again.expect("the mode is ours to set");
std::fs::remove_dir_all(&locked).expect("the directory is ours to remove");
assert!(
!said.contains("installed"),
"a machine with no bd on it was told bd is installed: {said}"
);
}
fn an_unsearchable_directory(named: &str) -> PathBuf {
let locked =
std::env::temp_dir().join(format!("bdi-locked-{named}-{}", std::process::id()));
std::fs::create_dir_all(&locked).expect("the directory is ours to make");
std::fs::set_permissions(&locked, Permissions::from_mode(0o644))
.expect("the mode is ours to set");
locked
}
#[test]
fn every_way_a_tracker_fails_keeps_its_own_kind() {
let kinds = [
(FailureKind::Auth, TrackerFailure::Auth),
(FailureKind::Unavailable, TrackerFailure::Unavailable),
(FailureKind::NotInstalled, TrackerFailure::NotInstalled),
(FailureKind::Unstartable, TrackerFailure::Unstartable),
(
FailureKind::InstalledUnstartable,
TrackerFailure::InstalledUnstartable,
),
(
FailureKind::Parse,
TrackerFailure::Parse(Unreadable::default()),
),
(FailureKind::Unsupported, TrackerFailure::Unavailable),
(FailureKind::UnknownFlag, TrackerFailure::UnknownFlag),
];
for (kind, expected) in kinds {
let trackers = orbital_with(orbital_tracker().failing(Asked::All, failing(kind)));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert_eq!(snap.failed_projects[0].tracker, expected, "on {kind:?}");
}
}
#[test]
fn a_tracker_whose_answer_would_not_parse_keeps_what_would_not_parse() {
let would_not_parse =
RunFailure::parse("bd", "invalid type: null, expected a string").reading("list");
let trackers = orbital_with(orbital_tracker().failing(Asked::All, would_not_parse));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert_eq!(
snap.failed_projects[0].tracker,
TrackerFailure::Parse(Unreadable {
read: "list".to_string(),
cause: "invalid type: null, expected a string".to_string(),
})
);
}
#[test]
fn a_project_with_no_environment_is_its_own_failure_rather_than_bds() {
let trackers = orbital().without_the_environment_it_asked_for("orbital");
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert_eq!(
snap.failed_projects[0].tracker,
TrackerFailure::NoEnvironment
);
assert_eq!(snap.failed_projects[0].project, "orbital");
}
#[test]
fn a_root_the_answer_does_not_hold_keeps_its_id_and_is_never_hidden() {
let cfg = Config::from_toml(&format!(
r#"
[[projects]]
name = "orbital"
path = "{ORBITAL}"
[roots.explicit]
orbital = ["orb-404"]
"#
))
.expect("the config parses");
let snap = run(&cfg, &panes(), &orbital(), Filter::LiveAgents, now());
assert!(
snap.failed_projects.is_empty(),
"the project's own tracker answered"
);
let roots: BTreeSet<&str> = snap.trees.iter().map(|t| t.root.as_str()).collect();
assert_eq!(roots, BTreeSet::from(["orb-404", "orb-7"]));
assert_eq!(
rooted_at(&snap, "orb-404").tracker,
TrackerState::RootNotFound
);
assert!(snap.hidden_trees.is_empty());
}
#[test]
fn the_one_tracker_read_failing_takes_the_project_down_by_name() {
let trackers =
orbital_with(orbital_tracker().failing(Asked::All, failing(FailureKind::Unavailable)));
let snap = run(
&one_project(),
&panes(),
&trackers,
Filter::LiveAgents,
now(),
);
assert_eq!(
snap.failed_projects,
vec![FailedProject {
project: "orbital".to_string(),
tracker: TrackerFailure::Unavailable,
}]
);
assert!(snap.trees.is_empty());
assert!(snap.hidden_trees.is_empty());
assert_eq!(
snap.unattributed
.iter()
.map(|pane| pane.pane.id.as_str())
.collect::<Vec<&str>>(),
vec!["w:p1", "w:p9"],
"the panes working in it are recovered rather than lost with it"
);
}
#[test]
fn a_tracker_holding_no_bead_draws_nothing_and_fails_nothing() {
let trackers = orbital_with(orbital_holding("[]"));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert!(snap.trees.is_empty());
assert!(
snap.failed_projects.is_empty(),
"{:?}",
snap.failed_projects
);
}
#[test]
fn a_trackers_own_words_never_reach_the_snapshot() {
let trackers = orbital_with(orbital_tracker().failing(
Asked::All,
RunFailure {
kind: FailureKind::Auth,
program: "bd".to_string(),
detail: "Access denied for user 'orbital' at db.example.invalid:3306".to_string(),
unreadable: None,
},
));
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
let json = serde_json::to_string(&snap).expect("the snapshot serialises");
for leak in ["Access denied", "db.example.invalid", "'orbital'", "3306"] {
assert!(!json.contains(leak), "{leak:?} survived into {json}");
}
}
#[test]
fn a_tracker_that_cannot_answer_readiness_fails_rather_than_calling_every_bead_unready() {
for question in [Asked::Ready, Asked::Blocked] {
let trackers = orbital_with(
orbital_tracker().failing(question, failing(FailureKind::Unavailable)),
);
let snap = run(&one_project(), &panes(), &trackers, Filter::All, now());
assert!(snap.trees.is_empty(), "on {question:?}");
assert_eq!(
snap.failed_projects[0].tracker,
TrackerFailure::Unavailable,
"on {question:?}"
);
}
}
#[test]
fn each_project_is_drawn_from_the_tracker_opened_for_it() {
let trackers = Fakes::default()
.with("orbital", orbital_tracker())
.with("ferry", colliding_tracker());
let snap = run(&two_projects(), &no_panes(), &trackers, Filter::All, now());
let roots: Vec<(&str, &str)> = snap
.trees
.iter()
.map(|t| (t.project.as_str(), t.root.as_str()))
.collect();
assert_eq!(roots, vec![("orbital", "orb-7"), ("ferry", "x-1")]);
}
#[test]
fn a_project_whose_tracker_cannot_be_opened_is_named_with_that_failure() {
let trackers = Fakes::default()
.without_the_credential_it_asked_for("orbital")
.with("ferry", colliding_tracker());
let snap = run(&two_projects(), &no_panes(), &trackers, Filter::All, now());
assert_eq!(
snap.failed_projects,
vec![FailedProject {
project: "orbital".to_string(),
tracker: TrackerFailure::NoCredential,
}]
);
let roots: Vec<(&str, &str)> = snap
.trees
.iter()
.map(|t| (t.project.as_str(), t.root.as_str()))
.collect();
assert_eq!(roots, vec![("ferry", "x-1")]);
}
}