#[allow(clippy::wildcard_imports)]
use super::*;
use crate::relationships::{IssueView, RelationKind, Relationship};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Node {
number: u32,
title: String,
status: String,
epics: Vec<String>,
labels: Vec<String>,
}
impl Node {
fn new(number: u32, title: &str) -> Self {
Self {
number,
title: title.to_string(),
status: "todo".to_string(),
epics: Vec::new(),
labels: Vec::new(),
}
}
fn with(mut self, ids: &[&str]) -> Self {
self.epics = ids.iter().map(|id| (*id).to_string()).collect();
self
}
fn in_column(mut self, status: &str) -> Self {
self.status = status.to_string();
self
}
fn labelled(mut self, label: &str) -> Self {
self.labels.push(label.to_string());
self
}
}
impl EpicNode for Node {
fn number(&self) -> u32 {
self.number
}
fn title(&self) -> &str {
&self.title
}
fn status(&self) -> &str {
&self.status
}
fn epic_ids(&self) -> &[String] {
&self.epics
}
}
fn registry(entries: &[(&str, &str)]) -> Config {
let mut config = Config::default();
for (id, name) in entries {
config.epics.insert(
(*id).to_string(),
EpicConfiguration {
name: (*name).to_string(),
..EpicConfiguration::default()
},
);
}
config
}
fn record(name: &str, parent: Option<&str>, weight: Option<i64>) -> EpicConfiguration {
EpicConfiguration {
name: name.to_string(),
parent: parent.map(str::to_string),
weight,
..EpicConfiguration::default()
}
}
fn ids(change: &EpicChange) -> Vec<&str> {
change.epic_ids.iter().map(String::as_str).collect()
}
fn epics(config: &Config) -> &IndexMap<String, EpicConfiguration> {
&config.epics
}
fn targets(projects: &[&str], milestones: &[&str], cycles: &[&str]) -> ReferenceTargets {
ReferenceTargets {
projects: projects.iter().map(|id| (*id).to_string()).collect(),
milestones: milestones.iter().map(|m| (*m).to_string()).collect(),
cycles: cycles.iter().map(|c| (*c).to_string()).collect(),
}
}
fn no_issues() -> Vec<Node> {
Vec::new()
}
struct Blocking {
issues: Vec<Node>,
edges: Vec<Vec<Relationship>>,
}
impl Blocking {
fn new(issues: Vec<Node>, blocks: &[(u32, u32)]) -> Self {
let edges = issues
.iter()
.map(|issue| {
blocks
.iter()
.filter(|(blocker, _)| *blocker == issue.number)
.map(|(blocker, blocked)| {
Relationship::new(*blocker, *blocked, RelationKind::Blocks, "blocked")
})
.collect()
})
.collect();
Self { issues, edges }
}
fn views(&self) -> Vec<IssueView<'_>> {
self.issues
.iter()
.zip(self.edges.iter())
.map(|(issue, edges)| {
IssueView::new(issue.number, &issue.title, &issue.status, edges.as_slice())
})
.collect()
}
}
fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
let mut config = Config::default();
config.kanban.configurations.insert(
"default".to_string(),
crate::config::BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done_column.to_string()),
category_map: map
.iter()
.map(|(column, key)| ((*column).to_string(), (*key).to_string()))
.collect(),
..Default::default()
},
);
config
}
#[test]
fn t006_create_epic_allocates_a_stable_id_and_the_metadata() {
let mut config = Config::default();
let new = NewEpic {
name: "Harden the sync path".to_string(),
description: Some("Reduce operational risk.".to_string()),
status: Some("active".to_string()),
owner: Some("Alice <alice@example.com>".to_string()),
start_date: Some("2026-09-01".to_string()),
due_date: Some("2026-12-31".to_string()),
weight: Some(5),
..NewEpic::default()
};
let epic = create_epic(&mut config, &new).unwrap();
assert!(epic.id.as_str().starts_with("e-"), "{}", epic.id);
assert_eq!(epic.name, "Harden the sync path");
assert_eq!(epic.weight, Some(5));
assert_eq!(epic.member_count, 0);
assert_eq!(epic.members, [] as [crate::projects::MemberRef; 0]);
assert_eq!(epic.children, [] as [crate::epics::EpicRef; 0]);
assert!(config.epics.contains_key(epic.id.as_str()));
let second = create_epic(&mut config, &new).unwrap_err();
assert_eq!(second.kind(), EpicDiagnosticKind::DuplicateName);
let other = NewEpic {
name: "Other".to_string(),
..NewEpic::default()
};
assert_ne!(create_epic(&mut config, &other).unwrap().id, epic.id);
}
#[test]
fn t006_create_epic_refuses_a_duplicate_name_and_bad_metadata() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let cases = [
(
NewEpic {
name: "platform".to_string(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: " padded".to_string(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: String::new(),
..NewEpic::default()
},
"duplicate_name",
),
(
NewEpic {
name: "Dates".to_string(),
start_date: Some("2026-12-31".to_string()),
due_date: Some("2026-01-01".to_string()),
..NewEpic::default()
},
"invalid_dates",
),
(
NewEpic {
name: "Heavy".to_string(),
weight: Some(0),
..NewEpic::default()
},
"invalid_weight",
),
];
for (new, kind) in cases {
let err = create_epic(&mut config, &new).unwrap_err();
assert_eq!(err.kind().as_str(), kind, "{err}");
}
assert_eq!(config.epics.len(), 1);
}
#[test]
fn t006_a_rename_changes_the_label_only() {
let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Child")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
let before = config.epics["e-aaaa"].clone();
let edit = EpicEdit {
name: Patch::Set("Platform hardening".to_string()),
..EpicEdit::default()
};
let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
assert_eq!(edited.name, "Platform hardening");
assert_eq!(edited.id.as_str(), "e-aaaa", "a rename never re-keys");
assert_eq!(config.epics["e-aaaa"].name, "Platform hardening");
assert_eq!(config.epics["e-aaaa"].description, before.description);
let index = EpicIndex::build(&config, &issues);
assert_eq!(index.membership("e-aaaa").unwrap().member_count, 1);
assert_eq!(
index.hierarchy("e-bbbb").unwrap().parent.unwrap().name,
"Platform hardening"
);
assert_eq!(
index.resolve("Platform hardening").unwrap().id.as_str(),
"e-aaaa"
);
}
#[test]
fn t006_delete_reports_affected_issues_and_child_epics_and_keeps_them() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
let issues = vec![
Node::new(1, "Member one").with(&["e-aaaa"]),
Node::new(2, "Member two").with(&["e-aaaa", "e-bbbb"]),
];
let deletion = delete_epic(&mut config, "e-aaaa", &issues).unwrap();
assert_eq!(deletion.id.as_str(), "e-aaaa");
assert_eq!(deletion.name, "Platform");
assert_eq!(
deletion
.affected
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(deletion.affected[0].display, "#1 Member one");
assert_eq!(
deletion
.child_epics
.iter()
.map(|e| e.id.as_str())
.collect::<Vec<_>>(),
vec!["e-bbbb"]
);
assert!(!config.epics.contains_key("e-aaaa"));
assert!(config.epics.contains_key("e-bbbb"));
assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
assert_eq!(issues.len(), 2, "no issue is deleted");
let index = EpicIndex::build(&config, &issues);
assert!(
index
.missing()
.iter()
.all(|m| m.target_kind == EpicReferenceKind::Epic)
);
assert_eq!(index.missing().len(), 2);
assert_eq!(
index
.hierarchy("e-bbbb")
.unwrap()
.unresolved
.unwrap()
.recorded_parent,
"e-aaaa"
);
let fresh = create_epic(
&mut config,
&NewEpic {
name: "Fresh".to_string(),
..NewEpic::default()
},
)
.unwrap();
assert_ne!(fresh.id.as_str(), "e-aaaa");
}
#[test]
fn t006_resolution_by_id_and_by_name_agree() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.resolve("e-aaaa").unwrap().name, "Platform");
assert_eq!(index.resolve("Platform").unwrap().id.as_str(), "e-aaaa");
assert_eq!(
index.resolve("Platform").unwrap(),
index.resolve("e-aaaa").unwrap()
);
assert_eq!(index.resolve(" sync ").unwrap().id.as_str(), "e-bbbb");
}
#[test]
fn t006_an_unknown_id_and_an_unknown_name_agree_on_unknown_epic() {
let config = registry(&[("e-aaaa", "Platform")]);
let index = EpicIndex::build(&config, &no_issues());
for reference in ["e-zzzz", "Nowhere"] {
let err = index.resolve(reference).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::UnknownEpic, "{err}");
assert_eq!(err.kind().as_str(), "unknown_epic");
assert!(err.message().contains(reference));
}
}
#[test]
fn t006_list_epics_is_declaration_ordered_with_derived_counts() {
let mut config = registry(&[("e-bbbb", "Second"), ("e-aaaa", "First")]);
config
.epics
.insert("e-cccc".to_string(), record("Child", Some("e-aaaa"), None));
config.epics.shift_remove("e-aaaa");
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "First".to_string(),
..Default::default()
},
);
let issues = vec![
Node::new(1, "One").with(&["e-aaaa"]),
Node::new(2, "Two").with(&["e-aaaa"]),
];
let listed = list_epics(&config, &issues);
assert_eq!(
listed.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
vec!["e-bbbb", "e-cccc", "e-aaaa"]
);
let first = &listed[2];
assert_eq!(first.member_count, 2);
assert_eq!(first.child_count, 1);
assert_eq!(first.children[0].id.as_str(), "e-cccc");
assert_eq!(first.children[0].display, "Child (e-cccc)");
assert_eq!(
list_epics(&Config::default(), &no_issues()),
[] as [crate::epics::Epic; 0]
);
}
#[test]
fn t006_an_invalid_weight_is_refused_on_edit_and_a_valid_one_is_kept() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let bad = EpicEdit {
weight: Patch::Set(0),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &bad).unwrap_err();
assert_eq!(err.kind().as_str(), "invalid_weight");
assert_eq!(
config.epics["e-aaaa"].weight, None,
"a refusal writes nothing"
);
let good = EpicEdit {
weight: Patch::Set(8),
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &good).unwrap().weight,
Some(8)
);
let cleared = EpicEdit {
weight: Patch::Clear,
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &cleared).unwrap().weight,
None
);
}
#[test]
fn t007_membership_is_ordered_by_issue_number_and_stable_across_rebuilds() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let issues = vec![
Node::new(9, "Nine").with(&["e-aaaa"]),
Node::new(3, "Three").with(&["e-aaaa", "e-bbbb"]),
Node::new(7, "Seven").with(&["e-aaaa", "e-aaaa"]),
];
let index = EpicIndex::build(&config, &issues);
let membership = index.membership("e-aaaa").unwrap();
assert_eq!(
membership
.members
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![3, 7, 9]
);
assert_eq!(membership.member_count, 3);
assert_eq!(membership.members[0].display, "#3 Three");
assert_eq!(index.membership("e-bbbb").unwrap().member_count, 1);
assert_eq!(
index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
3
);
assert_eq!(index, EpicIndex::build(&config, &issues));
}
#[test]
fn t007_two_epics_sharing_an_issue_both_hold_it_and_an_unreferenced_issue_is_in_none() {
let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
let issues = vec![
Node::new(1, "Shared").with(&["e-aaaa", "e-bbbb"]),
Node::new(2, "Lonely"),
];
let buckets = membership_index(&config, &issues);
assert_eq!(
buckets[&EpicId::new("e-aaaa").unwrap()],
vec![IssueKey::new(1)]
);
assert_eq!(
buckets[&EpicId::new("e-bbbb").unwrap()],
vec![IssueKey::new(1)]
);
assert!(!buckets.values().flatten().any(|key| key.number() == 2));
let config = registry(&[("e-cccc", "Empty")]);
let buckets = membership_index(&config, &no_issues());
assert_eq!(buckets.get(&EpicId::new("e-cccc").unwrap()), None);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.bucket(&EpicId::new("e-cccc").unwrap()), Some(&[][..]));
assert!(index.bucket(&EpicId::new("e-zzzz").unwrap()).is_none());
}
#[test]
fn t007_a_missing_epic_reference_is_accounted_for_rather_than_dropped() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![Node::new(4, "Dangling").with(&["e-gone", "e-aaaa"])];
let index = EpicIndex::build(&config, &issues);
assert_eq!(
index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
1
);
assert_eq!(index.missing().len(), 1);
let missing = &index.missing()[0];
assert_eq!(missing.target_kind, EpicReferenceKind::Epic);
assert_eq!(missing.target, "e-gone");
assert_eq!(missing.holder, ReferenceHolder::Issue(IssueKey::new(4)));
assert_eq!(missing.holder.to_string(), "issue #4");
assert!(missing.reason.contains("e-gone"));
}
#[test]
fn t007_no_member_list_is_stored_and_a_reference_change_leaves_the_record_alone() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Member").with(&["e-aaaa"]),
Node::new(2, "Second"),
];
let record_before = config.epics["e-aaaa"].clone();
let change = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert_eq!(ids(&change), vec!["e-aaaa"]);
assert_eq!(config.epics["e-aaaa"], record_before);
let serialized = toml::to_string(&config.epics["e-aaaa"]).unwrap();
assert!(!serialized.contains("members"), "{serialized}");
assert!(!serialized.contains("issues"), "{serialized}");
}
#[test]
fn t007_a_task_labelled_issue_is_a_member_like_any_other() {
let task = Node::new(1, "A task").with(&["e-aaaa"]).labelled("task");
let ordinary = Node::new(2, "An issue").with(&["e-aaaa"]);
assert_eq!(task.labels, vec!["task".to_string()]);
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![task, ordinary];
let index = EpicIndex::build(&config, &issues);
let members = index.membership("e-aaaa").unwrap();
assert_eq!(
members
.members
.iter()
.map(|m| m.issue.number())
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(index.buckets().len(), 1);
let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert!(added.event.is_none(), "it already carries the reference");
assert_eq!(ids(&added), vec!["e-aaaa"]);
let changed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
assert_eq!(ids(&changed), [] as [&str; 0]);
assert_eq!(changed.event.unwrap().kind, EpicEventKind::EpicDereferenced);
}
#[test]
fn t008_a_three_deep_chain_reports_ancestors_children_and_depth() {
let mut config = registry(&[("e-root", "Root")]);
config
.epics
.insert("e-mid".to_string(), record("Mid", Some("e-root"), None));
config
.epics
.insert("e-leaf".to_string(), record("Leaf", Some("e-mid"), None));
let index = EpicIndex::build(&config, &no_issues());
let leaf = index.hierarchy("e-leaf").unwrap();
assert_eq!(leaf.parent.unwrap().id.as_str(), "e-mid");
assert_eq!(leaf.recorded_parent.as_deref(), Some("e-mid"));
assert_eq!(leaf.depth, 2);
assert_eq!(
leaf.ancestors
.iter()
.map(|a| a.id.as_str())
.collect::<Vec<_>>(),
vec!["e-root", "e-mid"],
"ancestors are root-first"
);
assert!(leaf.children.is_empty() && leaf.child_count == 0);
assert!(leaf.unresolved.is_none());
let root = index.hierarchy("e-root").unwrap();
assert!(root.parent.is_none() && root.ancestors.is_empty() && root.depth == 0);
assert_eq!(
root.children
.iter()
.map(|c| c.id.as_str())
.collect::<Vec<_>>(),
vec!["e-mid"]
);
assert_eq!(root.children[0].display, "Mid (e-mid)");
}
#[test]
fn t008_a_self_parent_is_rejected_before_any_write() {
let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Other")]);
let before = config.clone();
let edit = EpicEdit {
parent: Patch::Set("e-aaaa".to_string()),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
assert_eq!(err.kind().as_str(), "self_parent");
assert_eq!(
config, before,
"a refusal leaves the registry byte-unchanged"
);
let by_name = EpicEdit {
parent: Patch::Set("Platform".to_string()),
..EpicEdit::default()
};
assert_eq!(
edit_epic(&mut config, "e-aaaa", &by_name)
.unwrap_err()
.kind(),
EpicDiagnosticKind::SelfParent
);
assert_eq!(config, before);
}
#[test]
fn t008_a_cycle_closing_parent_is_rejected_with_the_path() {
let mut config = registry(&[("e-aaaa", "A")]);
config
.epics
.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
config
.epics
.insert("e-cccc".to_string(), record("C", Some("e-bbbb"), None));
let before = config.epics.clone();
let edit = EpicEdit {
parent: Patch::Set("e-cccc".to_string()),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
let EpicError::Cycle { path, .. } = &err else {
panic!("expected a cycle refusal, got {err}");
};
assert_eq!(
path.iter().map(EpicId::as_str).collect::<Vec<_>>(),
vec!["e-aaaa", "e-cccc", "e-bbbb"]
);
assert!(err.message().contains("e-cccc"));
assert_eq!(config.epics, before, "SC-005: nothing was written");
assert!(config.epics["e-aaaa"].parent.is_none());
}
#[test]
fn t008_a_ten_thousand_node_chain_walks_without_overflow() {
const N: usize = 10_000;
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-00000".to_string(), record("Root", None, None));
for n in 1..N {
let parent = format!("e-{:05}", n - 1);
registry.insert(format!("e-{n:05}"), record("Node", Some(&parent), None));
}
let root = EpicId::new("e-00000").unwrap();
let deepest = format!("e-{:05}", N - 1);
let err = validate_epic_parent(®istry, &root, &deepest).unwrap_err();
let EpicError::Cycle { path, .. } = &err else {
panic!("expected a cycle refusal, got {err}");
};
assert_eq!(path.len(), N);
assert_eq!(path[0].as_str(), "e-00000");
assert_eq!(path[1].as_str(), deepest);
let fresh = EpicId::new("e-new").unwrap();
assert_eq!(
validate_epic_parent(®istry, &fresh, &deepest)
.unwrap()
.as_str(),
deepest
);
let config = Config {
epics: registry,
..Config::default()
};
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.hierarchy(&deepest).unwrap().depth, N - 1);
}
#[test]
fn t008_a_parent_naming_a_deleted_epic_is_unresolved_not_dropped() {
let mut config = registry(&[("e-aaaa", "Gone")]);
config
.epics
.insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
config.epics.shift_remove("e-aaaa");
let index = EpicIndex::build(&config, &no_issues());
let hierarchy = index.hierarchy("e-bbbb").unwrap();
assert!(hierarchy.parent.is_none(), "it does not resolve");
assert_eq!(hierarchy.recorded_parent.as_deref(), Some("e-aaaa"));
let unresolved = hierarchy.unresolved.unwrap();
assert_eq!(unresolved.epic.as_str(), "e-bbbb");
assert_eq!(unresolved.recorded_parent, "e-aaaa");
assert!(unresolved.reason.contains("e-aaaa"));
assert!(unresolved.reason.contains("e-bbbb"));
assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
let edit = EpicEdit {
parent: Patch::Clear,
..EpicEdit::default()
};
let mut config = config;
edit_epic(&mut config, "e-bbbb", &edit).unwrap();
assert!(config.epics["e-bbbb"].parent.is_none());
}
#[test]
fn t008_entering_a_stored_cycle_is_not_closing_one() {
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
registry.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
registry.insert("e-cccc".to_string(), record("C", Some("e-aaaa"), None));
registry.insert("e-dddd".to_string(), record("D", None, None));
let c = EpicId::new("e-cccc").unwrap();
assert_eq!(
validate_epic_parent(®istry, &c, "e-aaaa")
.unwrap()
.as_str(),
"e-aaaa"
);
let fresh = EpicId::new("e-new1").unwrap();
assert_eq!(
validate_epic_parent(®istry, &fresh, "e-bbbb")
.unwrap()
.as_str(),
"e-bbbb"
);
let defects = validate_epic_chains(®istry);
assert_eq!(defects.len(), 1, "{defects:#?}");
assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
let err = validate_epic_parent(®istry, &c, "e-cccc").unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
let closes = validate_epic_parent(®istry, &EpicId::new("e-dddd").unwrap(), "e-aaaa");
assert_eq!(closes.unwrap().as_str(), "e-aaaa");
}
#[test]
fn t008_a_stored_self_parent_is_reported_once() {
let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
registry.insert("e-aaaa".to_string(), record("A", Some("e-aaaa"), None));
registry.insert("e-bbbb".to_string(), record("B", None, None));
let defects = validate_epic_chains(®istry);
assert_eq!(defects.len(), 1, "one defect, not two: {defects:#?}");
assert_eq!(defects[0].kind(), EpicDiagnosticKind::SelfParent);
assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
}
#[test]
fn t006_the_reference_fields_are_writable_through_the_service() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let created = create_epic(
&mut config,
&NewEpic {
name: "Full".to_string(),
projects: vec!["p-ok1".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..NewEpic::default()
},
)
.unwrap();
assert_eq!(created.parent, None);
assert_eq!(config.epics[created.id.as_str()].projects, vec!["p-ok1"]);
assert_eq!(
config.epics[created.id.as_str()].milestone.as_deref(),
Some("2026-Q4")
);
let edit = EpicEdit {
projects: Patch::Set(vec!["p-gone".to_string(), "p-gone".to_string()]),
milestone: Patch::Set("Nowhere".to_string()),
cycle: Patch::Clear,
..EpicEdit::default()
};
let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
assert_eq!(edited.projects, vec!["p-gone"]);
assert_eq!(edited.milestone.as_deref(), Some("Nowhere"));
assert_eq!(edited.cycle, None);
let record = &config.epics["e-aaaa"];
assert_eq!(
record.projects,
vec!["p-gone"],
"a duplicate collapses to one"
);
assert_eq!(record.milestone.as_deref(), Some("Nowhere"));
assert_eq!(record.cycle, None);
let found = resolve_references(epics(&config), &no_issues(), &ReferenceTargets::default());
let kinds_of = |epic: &EpicId| -> Vec<EpicReferenceKind> {
found
.iter()
.filter(|f| f.holder == ReferenceHolder::Epic(epic.clone()))
.map(|f| f.target_kind)
.collect()
};
assert_eq!(
kinds_of(&EpicId::new("e-aaaa").unwrap()),
vec![EpicReferenceKind::Project, EpicReferenceKind::Milestone]
);
assert_eq!(
kinds_of(&created.id),
vec![
EpicReferenceKind::Project,
EpicReferenceKind::Milestone,
EpicReferenceKind::Cycle
]
);
}
#[test]
fn t006_a_malformed_project_id_is_refused_before_any_write() {
let mut config = registry(&[("e-aaaa", "Platform")]);
let before = config.clone();
let bad_edit = EpicEdit {
projects: Patch::Set(vec!["Not A Project".to_string()]),
..EpicEdit::default()
};
let err = edit_epic(&mut config, "e-aaaa", &bad_edit).unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::InvalidReference);
assert_eq!(err.kind().as_str(), "invalid_reference");
assert!(err.message().contains("Not A Project"));
assert_eq!(config, before, "a refusal writes nothing");
let bad_create = create_epic(
&mut config,
&NewEpic {
name: "Bad refs".to_string(),
projects: vec![" p-ok1".to_string()],
..NewEpic::default()
},
);
assert_eq!(
bad_create.unwrap_err().kind(),
EpicDiagnosticKind::InvalidReference
);
assert_eq!(config, before);
}
#[test]
fn t008_a_stored_cycle_is_reported_by_the_chain_validation() {
let mut config = registry(&[("e-aaaa", "A")]);
config
.epics
.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
config
.epics
.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
let defects = validate_epic_chains(epics(&config));
assert_eq!(defects.len(), 1);
assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
let index = EpicIndex::build(&config, &no_issues());
assert_eq!(index.hierarchy("e-aaaa").unwrap().depth, 1);
}
#[test]
fn t009_each_missing_reference_kind_reports_exactly_one_named_entry() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
EpicConfiguration {
name: "Broken".to_string(),
parent: Some("e-aaaa".to_string()),
projects: vec!["p-gone".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Dangling").with(&["e-missing"]),
Node::new(2, "Malformed").with(&["Not An Id"]),
];
let targets = targets(&[], &[], &[]);
let found = resolve_references(epics(&config), &issues, &targets);
assert_eq!(found.len(), 5, "{found:#?}");
for kind in [
EpicReferenceKind::Epic,
EpicReferenceKind::Project,
EpicReferenceKind::Issue,
EpicReferenceKind::Milestone,
EpicReferenceKind::Cycle,
] {
let matching: Vec<&EpicReferenceResolution> =
found.iter().filter(|f| f.target_kind == kind).collect();
assert_eq!(matching.len(), 1, "{kind} must be diagnosed once");
let record = matching[0];
assert!(!record.target.is_empty() || kind == EpicReferenceKind::Issue);
assert_ne!(record.reason, "");
assert!(record.reason.contains(&record.target) || record.target.is_empty());
assert_eq!(
kind.diagnostic_kind(),
format!("missing_{}_reference", kind.as_str())
);
}
let by_kind = |kind: EpicReferenceKind| {
found
.iter()
.find(|f| f.target_kind == kind)
.unwrap()
.clone()
};
let epic_ref = by_kind(EpicReferenceKind::Epic);
assert_eq!(epic_ref.target, "e-missing");
assert_eq!(epic_ref.holder, ReferenceHolder::Issue(IssueKey::new(1)));
assert_eq!(epic_ref.holder.to_string(), "issue #1");
assert_eq!(by_kind(EpicReferenceKind::Project).target, "p-gone");
assert_eq!(by_kind(EpicReferenceKind::Milestone).target, "2026-Q4");
assert_eq!(by_kind(EpicReferenceKind::Cycle).target, "October 2026");
assert_eq!(by_kind(EpicReferenceKind::Issue).target, "Not An Id");
assert_eq!(
by_kind(EpicReferenceKind::Issue).holder,
ReferenceHolder::Issue(IssueKey::new(2))
);
}
#[test]
fn t009_a_clean_snapshot_reports_nothing_and_a_resolvable_one_does_too() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
EpicConfiguration {
name: "Complete".to_string(),
parent: Some("e-aaaa".to_string()),
projects: vec!["p-aaaa".to_string()],
milestone: Some("2026-Q4".to_string()),
cycle: Some("October 2026".to_string()),
..EpicConfiguration::default()
},
);
let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
let targets = targets(&["p-aaaa"], &["2026-Q4"], &["October 2026"]);
assert_eq!(
resolve_references(epics(&config), &issues, &targets),
[] as [crate::epics::EpicReferenceResolution; 0]
);
assert_eq!(
resolve_references(
epics(®istry(&[("e-aaaa", "Bare")])),
&no_issues(),
&targets
),
[] as [crate::epics::EpicReferenceResolution; 0]
);
}
#[test]
fn t009_two_runs_over_the_same_input_are_equal_and_ordered() {
let mut config = registry(&[("e-bbbb", "B")]);
config
.epics
.insert("e-aaaa".to_string(), record("A", Some("e-gone"), None));
config
.epics
.insert("e-cccc".to_string(), record("C", Some("e-gone"), None));
let issues = vec![
Node::new(5, "Five").with(&["e-x1"]),
Node::new(2, "Two").with(&["e-x2"]),
];
let first = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
let second = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
assert_eq!(first, second);
assert_eq!(
first
.iter()
.map(|f| (f.holder.to_string(), f.target.clone()))
.collect::<Vec<_>>(),
vec![
("epic `e-aaaa`".to_string(), "e-gone".to_string()),
("epic `e-cccc`".to_string(), "e-gone".to_string()),
("issue #2".to_string(), "e-x2".to_string()),
("issue #5".to_string(), "e-x1".to_string()),
]
);
assert!(
first
.windows(2)
.all(|w| reference_order(&w[0], &w[1]) != std::cmp::Ordering::Greater)
);
}
#[test]
fn t009_targets_come_from_the_planner_lookups_not_a_second_store() {
let mut config = Config::default();
config.cycles.insert(
"October 2026".to_string(),
crate::config::PlanningCycle::default(),
);
let planner = Planner::from_config(config, PathBuf::from("."));
let resolved = ReferenceTargets::from_planner(&planner).unwrap();
assert!(resolved.cycle_known("October 2026"));
assert!(resolved.cycle_known(DEFAULT_CYCLE_NAME));
assert!(!resolved.cycle_known("Nowhere"));
assert!(!resolved.milestone_known("2026-Q4"));
}
#[test]
fn t010_three_members_one_complete_report_done_one_of_three() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(
vec![
Node::new(1, "Complete").in_column("done").with(&["e-aaaa"]),
Node::new(2, "Open").with(&["e-aaaa"]),
Node::new(3, "Also open").with(&["e-aaaa"]),
],
&[],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.progress.total, 3);
assert_eq!(figures.progress.progress, Progress { done: 1, total: 3 });
assert_eq!(figures.child_count, 0);
assert_eq!(figures.blocked, 0);
assert_eq!(figures.weighted_progress.total_weight, 0);
assert_eq!(figures.weighted_progress.percent, None);
}
#[test]
fn t010_an_epic_with_no_members_reports_zeros() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(no_issues(), &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.progress, Progress { done: 0, total: 0 });
assert_eq!(figures.child_count, 0);
assert_eq!(figures.blocked, 0);
assert_eq!(
figures.blocked_members,
[] as [crate::epics::BlockedMember; 0]
);
assert_eq!(
figures.missing_references,
[] as [crate::epics::EpicReferenceResolution; 0]
);
}
#[test]
fn t010_completion_resolves_from_the_configured_column_not_a_literal() {
let mut config = custom_completion(
&["triage", "wip", "shipped"],
"shipped",
&[
("triage", "unstarted"),
("wip", "active"),
("shipped", "completed"),
],
);
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "Platform".to_string(),
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Shipped")
.in_column("shipped")
.with(&["e-aaaa"]),
Node::new(2, "Literal done")
.in_column("done")
.with(&["e-aaaa"]),
];
let fixture = Blocking::new(issues, &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(
figures.progress.done, 1,
"only the configured column counts"
);
assert_eq!(figures.progress.total, 2);
}
#[test]
fn t011_the_known_fixture_counts_every_figure() {
let mut config = registry(&[("e-parent", "Parent"), ("e-child", "Child")]);
config.epics.insert(
"e-parent".to_string(),
EpicConfiguration {
name: "Parent".to_string(),
weight: Some(5),
..EpicConfiguration::default()
},
);
config.epics.insert(
"e-child".to_string(),
EpicConfiguration {
name: "Child".to_string(),
parent: Some("e-parent".to_string()),
weight: Some(3),
projects: vec!["p-gone".to_string()],
..EpicConfiguration::default()
},
);
let issues = vec![
Node::new(1, "Complete")
.in_column("done")
.with(&["e-parent"]),
Node::new(2, "Blocked").with(&["e-parent"]),
Node::new(3, "Open").with(&["e-parent"]),
Node::new(4, "Child member")
.in_column("done")
.with(&["e-child"]),
];
let fixture = Blocking::new(issues, &[(3, 2)]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.epic_id.as_str(), "e-parent");
assert_eq!(figures.name, "Parent");
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.progress.total, 3);
assert_eq!(figures.child_count, 1);
assert_eq!(figures.blocked, 1);
assert_eq!(figures.blocked_members.len(), 1);
assert_eq!(figures.blocked_members[0].issue.number(), 2);
assert_ne!(figures.blocked_members[0].reason, "");
let weighted = &figures.weighted_progress;
assert_eq!(weighted.own_weight, Some(5));
assert_eq!(weighted.weighted_children, 1);
assert_eq!(weighted.unweighted_children, 0);
assert_eq!(weighted.total_weight, 3);
assert_eq!(weighted.done_weight, 3);
assert_eq!(weighted.percent, Some(100));
assert_eq!(
figures.missing_references,
[] as [crate::epics::EpicReferenceResolution; 0]
);
let child = rollup(
&config,
&fixture.issues,
"e-child",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(child.progress.progress, Progress { done: 1, total: 1 });
assert_eq!(child.child_count, 0);
assert_eq!(
child
.missing_references
.iter()
.map(|f| f.target_kind)
.collect::<Vec<_>>(),
vec![EpicReferenceKind::Project]
);
assert_eq!(child.weighted_progress.own_weight, Some(3));
assert_eq!(
figures,
rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph
)
.unwrap()
);
}
#[test]
fn t011_weighted_progress_excludes_an_unweighted_child() {
let mut config = registry(&[("e-parent", "Parent")]);
config.epics.insert(
"e-light".to_string(),
record("Light", Some("e-parent"), None),
);
config.epics.insert(
"e-heavy".to_string(),
record("Heavy", Some("e-parent"), Some(10)),
);
let fixture = Blocking::new(vec![], &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
let weighted = &figures.weighted_progress;
assert_eq!(
weighted.weighted_children, 1,
"the unweighted child is excluded"
);
assert_eq!(weighted.unweighted_children, 1);
assert_eq!(weighted.total_weight, 10, "not 10 + 0");
assert_eq!(
weighted.done_weight, 0,
"a child with no members is not complete"
);
assert_eq!(weighted.percent, Some(0));
}
#[test]
fn t011_weighted_percent_does_not_saturate_above_u32_weight() {
let mut config = registry(&[("e-parent", "Parent"), ("e-child", "Child")]);
config.epics.insert(
"e-parent".to_string(),
EpicConfiguration {
name: "Parent".to_string(),
..EpicConfiguration::default()
},
);
config.epics.insert(
"e-child".to_string(),
record("Child", Some("e-parent"), Some(100_000_000)),
);
let issues = vec![
Node::new(1, "Child member")
.in_column("done")
.with(&["e-child"]),
];
let fixture = Blocking::new(issues, &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-parent",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.weighted_progress.done_weight, 100_000_000);
assert_eq!(figures.weighted_progress.total_weight, 100_000_000);
assert_eq!(
figures.weighted_progress.percent,
Some(100),
"a complete epic stays 100% above u32::MAX / 100"
);
}
#[test]
fn t011_a_done_member_is_never_counted_as_blocked() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Done but claimed blocked")
.in_column("done")
.with(&["e-aaaa"]),
Node::new(2, "Genuinely blocked").with(&["e-aaaa"]),
Node::new(3, "An open blocker"),
];
let fixture = Blocking::new(issues, &[(1, 1), (3, 1), (3, 2)]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.done, 1);
assert_eq!(figures.blocked, 1);
assert_eq!(figures.blocked_members[0].issue.number(), 2);
}
#[test]
fn t011_the_blocked_read_is_consumed_from_the_relationship_graph() {
let config = registry(&[("e-aaaa", "Platform")]);
let fixture = Blocking::new(
vec![
Node::new(1, "Blocked").with(&["e-aaaa"]),
Node::new(2, "Blocker"),
],
&[(2, 1)],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let index = EpicIndex::build(&config, &fixture.issues);
let blocked = index.blocked_members(&EpicId::new("e-aaaa").unwrap(), &graph);
assert_eq!(blocked.len(), 1);
let owner = graph.blocked_state(1);
assert!(owner.is_blocked);
assert_eq!(blocked[0].reason, owner.reason);
}
#[test]
fn t011_missing_references_are_visible_on_the_rollup() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-aaaa".to_string(),
EpicConfiguration {
name: "Platform".to_string(),
parent: Some("e-gone".to_string()),
milestone: Some("Nowhere".to_string()),
..EpicConfiguration::default()
},
);
let fixture = Blocking::new(vec![], &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(
figures
.missing_references
.iter()
.map(|f| f.target_kind)
.collect::<Vec<_>>(),
vec![EpicReferenceKind::Epic, EpicReferenceKind::Milestone]
);
}
#[test]
fn t012_reference_is_idempotent_and_each_accepted_change_has_one_event() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "Member").with(&["e-aaaa"]),
Node::new(2, "Second"),
];
let noop = reference_epic(&config, &issues, 1, "e-aaaa").unwrap();
assert!(
noop.event.is_none(),
"an already-present reference changes nothing"
);
assert_eq!(ids(&noop), vec!["e-aaaa"]);
let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
assert_eq!(ids(&added), vec!["e-aaaa"]);
let event = added.event.unwrap();
assert_eq!(event.kind, EpicEventKind::EpicReferenced);
assert_eq!(event.kind.as_str(), "epic_referenced");
assert_eq!(event.detail, "epic `e-aaaa` referenced");
let removed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
assert_eq!(ids(&removed), [] as [&str; 0]);
let event = removed.event.clone().unwrap();
assert_eq!(event.kind.as_str(), "epic_dereferenced");
assert_eq!(event.detail, "epic `e-aaaa` dereferenced");
let absent = dereference_epic(&issues, 2, "e-aaaa").unwrap();
assert!(absent.event.is_none());
assert_eq!(ids(&absent), [] as [&str; 0]);
let stale = dereference_epic(&issues, 1, "e-gone").unwrap();
assert!(stale.event.is_none());
assert_eq!(ids(&stale), vec!["e-aaaa"]);
assert!(
dereference_epic(&issues, 1, "Platform")
.unwrap()
.event
.is_none()
);
}
#[test]
fn t012_bulk_reference_reports_each_selector_independently() {
let config = registry(&[("e-aaaa", "Platform")]);
let issues = vec![
Node::new(1, "One").with(&["e-aaaa"]),
Node::new(2, "Two"),
Node::new(3, "Three"),
];
let outcomes = bulk_reference(&config, &issues, "e-aaaa", &[1, 2, 99, 3]);
assert_eq!(outcomes.len(), 4);
assert_eq!(
outcomes.iter().map(|o| o.selector).collect::<Vec<_>>(),
vec![1, 2, 99, 3]
);
assert!(outcomes[0].outcome.as_ref().unwrap().event.is_none());
assert!(outcomes[1].outcome.as_ref().unwrap().event.is_some());
assert_eq!(
outcomes[2].outcome.as_ref().unwrap_err().kind(),
EpicDiagnosticKind::UnknownIssue
);
assert!(outcomes[3].outcome.as_ref().unwrap().event.is_some());
let all = bulk_reference(&config, &issues, "e-zzzz", &[1, 2]);
assert_eq!(all.len(), 2);
assert!(
all.iter()
.all(|o| o.outcome.as_ref().unwrap_err().kind() == EpicDiagnosticKind::UnknownEpic)
);
}
#[test]
fn t012_reference_epic_refuses_an_unknown_issue() {
let config = registry(&[("e-aaaa", "Platform")]);
let err = reference_epic(&config, &no_issues(), 7, "e-aaaa").unwrap_err();
assert_eq!(err.kind(), EpicDiagnosticKind::UnknownIssue);
assert!(err.message().contains('7'));
}
#[test]
fn t012_every_operation_in_the_contract_is_reachable() {
let mut config = registry(&[("e-aaaa", "Platform")]);
config.epics.insert(
"e-bbbb".to_string(),
record("Child", Some("e-aaaa"), Some(2)),
);
let issues = vec![Node::new(1, "Member").in_column("done").with(&["e-aaaa"])];
let fixture = Blocking::new(issues.clone(), &[]);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let _ = create_epic(
&mut config,
&NewEpic {
name: "Created".to_string(),
..NewEpic::default()
},
)
.unwrap();
let _ = edit_epic(
&mut config,
"e-bbbb",
&EpicEdit {
status: Patch::Set("active".to_string()),
projects: Patch::Set(vec!["p-ok1".to_string()]),
milestone: Patch::Set("2026-Q4".to_string()),
cycle: Patch::Set("October 2026".to_string()),
..EpicEdit::default()
},
)
.unwrap();
let _ = list_epics(&config, &issues);
let _ = resolve(&config, &issues, "e-aaaa").unwrap();
let _ = membership_index(&config, &issues);
let _ = membership(&config, &issues, "e-aaaa").unwrap();
let _ = hierarchy(&config, &issues, "e-aaaa").unwrap();
let _ = reference_epic(&config, &issues, 1, "e-bbbb").unwrap();
let _ = dereference_epic(&issues, 1, "e-aaaa").unwrap();
let _ = bulk_reference(&config, &issues, "e-aaaa", &[1]);
let _ = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
let _ = rollup(
&config,
&fixture.issues,
"e-aaaa",
&ReferenceTargets::default(),
&graph,
)
.unwrap();
let deletion = delete_epic(&mut config, "e-bbbb", &issues).unwrap();
assert_eq!(deletion.name, "Child");
assert_eq!(EpicDiagnosticKind::DuplicateName.as_str(), "duplicate_name");
assert_eq!(EpicDiagnosticKind::UnknownEpic.as_str(), "unknown_epic");
assert_eq!(EpicDiagnosticKind::UnknownIssue.as_str(), "unknown_issue");
assert_eq!(EpicDiagnosticKind::InvalidDates.as_str(), "invalid_dates");
assert_eq!(EpicDiagnosticKind::InvalidWeight.as_str(), "invalid_weight");
assert_eq!(
EpicDiagnosticKind::InvalidReference.as_str(),
"invalid_reference"
);
assert_eq!(EpicDiagnosticKind::SelfParent.as_str(), "self_parent");
assert_eq!(EpicDiagnosticKind::Cycle.as_str(), "cycle");
assert_eq!(EPICS_KEY, "epics");
assert_eq!(
epic_display(&EpicId::new("e-aaaa").unwrap(), "Platform"),
"Platform (e-aaaa)"
);
}
#[test]
fn t012_the_module_is_filesystem_free() {
let mut config = Config::default();
let created = create_epic(
&mut config,
&NewEpic {
name: "An epic".to_string(),
weight: Some(1),
..NewEpic::default()
},
)
.unwrap();
let fixture = Blocking::new(
vec![Node::new(1, "Member").with(&[created.id.as_str()])],
&[],
);
let views = fixture.views();
let graph = RelationshipGraph::new(&views, "done");
let figures = rollup(
&config,
&fixture.issues,
created.id.as_str(),
&ReferenceTargets::default(),
&graph,
)
.unwrap();
assert_eq!(figures.progress.total, 1);
assert_eq!(
resolve_references(
epics(&config),
&fixture.issues,
&ReferenceTargets::default()
),
[] as [crate::epics::EpicReferenceResolution; 0]
);
}