use crate::mspdi::tests::{document, import, link, outline_document, task, work, workspace};
use crate::{FieldChange, ImportResult, ItemOutcome, LinkOutcome};
use dpm_model::{Plan, Priority, WorkItemId, WorkKind};
use std::collections::BTreeSet;
fn reimport(edit: impl Fn(String) -> String) -> (Plan, ImportResult) {
let current = import(&workspace(), &outline_document("")).candidate;
let result = import(¤t, &edit(outline_document("")));
(current, result)
}
fn preserved(result: &ImportResult, uid: i64) -> Vec<String> {
result
.report
.items
.iter()
.find(|i| i.uid == uid)
.map(|i| i.preserved.clone())
.unwrap_or_default()
}
#[test]
fn absent_priority_keeps_the_local_priority() {
let (current, result) = reimport(|xml| xml.replace("<Priority>700</Priority>", ""));
assert_eq!(work(¤t, "MSP-3").schedule.priority, Priority::P1);
assert_eq!(
work(&result.candidate, "MSP-3").schedule.priority,
Priority::P1
);
assert_eq!(result.candidate, current);
assert_eq!(result.report.items[2].outcome, ItemOutcome::Unchanged);
assert!(!preserved(&result, 3).contains(&"priority".to_string()));
}
#[test]
fn empty_name_keeps_the_local_title() {
let (_, result) = reimport(|xml| xml.replace("<Name>Spec</Name>", "<Name></Name>"));
assert_eq!(work(&result.candidate, "MSP-2").title, "Spec");
assert_eq!(result.report.items[1].outcome, ItemOutcome::Unchanged);
assert!(!preserved(&result, 2).contains(&"title".to_string()));
}
#[test]
fn trailing_line_breaks_in_notes_are_not_content() {
let trailing =
|xml: String| xml.replace("<Notes>Write it</Notes>", "<Notes>Write it\n</Notes>");
let (_, result) = reimport(trailing);
assert_eq!(
work(&result.candidate, "MSP-2").contract.objective,
"Write it"
);
assert_eq!(result.report.items[1].outcome, ItemOutcome::Unchanged);
assert!(preserved(&result, 2).contains(&"notes".to_string()));
let created = import(&workspace(), &trailing(outline_document("")));
assert_eq!(
work(&created.candidate, "MSP-2").contract.objective,
"Write it"
);
}
#[test]
fn absent_milestone_flag_keeps_the_local_kind() {
let (_, result) = reimport(|xml| xml.replace("<Milestone>1</Milestone>", ""));
assert_eq!(work(&result.candidate, "MSP-4").kind, WorkKind::Milestone);
assert_eq!(result.report.items[3].outcome, ItemOutcome::Unchanged);
}
#[test]
fn a_task_without_a_milestone_flag_reports_its_kind_as_a_default() {
let created = import(&workspace(), &outline_document(""));
let spec = &created.report.items[1];
assert!(!spec.preserved.contains(&"kind".to_string()), "{spec:?}");
assert!(
spec.approximated
.iter()
.any(|f| f.field == "kind" && f.detail.contains("Task")),
"{spec:?}"
);
assert!(
created.report.items[3]
.preserved
.contains(&"kind".to_string())
);
assert!(
created.report.items[0]
.preserved
.contains(&"kind".to_string())
);
let (_, result) = reimport(|xml| xml);
let spec = &result.report.items[1];
assert!(!spec.preserved.contains(&"kind".to_string()), "{spec:?}");
assert!(spec.kept.contains(&"kind".to_string()), "{spec:?}");
}
#[test]
fn omitted_fields_are_reported_as_kept_and_new_work_reports_the_default_priority() {
let (_, result) = reimport(|xml| {
xml.replace("<Priority>700</Priority>", "")
.replace("<Notes>Write it</Notes>", "")
.replace("<Duration>PT16H0M0S</Duration>", "")
});
assert_eq!(result.report.items[2].kept, ["kind", "notes", "priority"]);
assert_eq!(
result.report.items[1].kept,
["kind", "notes", "priority", "duration"]
);
assert_eq!(result.report.items[3].kept, ["notes", "priority"]);
assert!(result.report.items.iter().all(|i| i.changes.is_empty()));
let created = import(&workspace(), &outline_document(""));
let spec = &created.report.items[1];
assert!(spec.kept.is_empty());
assert!(
spec.approximated
.iter()
.any(|f| f.field == "priority" && f.detail.contains("default 500"))
);
}
#[test]
fn updated_items_list_exactly_the_changed_local_fields() {
let (_, result) = reimport(|xml| {
xml.replace(">Spec<", ">Specification<")
.replace("<Priority>700</Priority>", "<Priority>300</Priority>")
});
let spec = &result.report.items[1];
assert_eq!(spec.outcome, ItemOutcome::Updated);
assert_eq!(
spec.changes,
[FieldChange {
field: "title".into(),
before: "Spec".into(),
after: "Specification".into(),
}]
);
let review = &result.report.items[2];
assert_eq!(review.changes[0].field, "priority");
assert_eq!(
(
review.changes[0].before.as_str(),
review.changes[0].after.as_str()
),
("P1", "P3")
);
}
#[test]
fn a_top_level_source_task_reports_its_kept_local_parent() {
let mut current = import(&workspace(), &outline_document("")).candidate;
let design = work(¤t, "MSP-1").id;
let build = work(¤t, "MSP-5").id;
current.work_items.get_mut(&build).expect("build").parent = Some(design);
let only_build = document(&[task(
5,
1,
"Build",
"<Duration>PT4H0M0S</Duration><DurationFormat>6</DurationFormat><Priority>500</Priority>",
)]);
let result = import(¤t, &only_build);
let item = &result.report.items[0];
assert_eq!(work(&result.candidate, "MSP-5").parent, Some(design));
assert_eq!(item.kept, ["kind", "notes", "outline"]);
assert!(!item.preserved.contains(&"outline".to_string()));
assert_eq!(item.outcome, ItemOutcome::Unchanged);
}
#[test]
fn kind_changes_name_the_source_task_and_the_attempted_change() {
let current = import(&workspace(), &outline_document("")).candidate;
let edited = outline_document("").replace(
"Approved</Name><OutlineLevel>2",
"Approved</Name><OutlineLevel>3",
);
let error = crate::import_mspdi(¤t, &edited, &crate::mspdi::tests::options())
.expect_err("kind change");
assert_eq!(error.code(), "invalid_command");
let message = error.to_string();
for expected in [
"UID 3",
"22222222-2222-4222-8222-000000000003",
"MSP-3",
"Task",
"WorkPackage",
] {
assert!(
message.to_uppercase().contains(&expected.to_uppercase()),
"{expected} missing from: {message}"
);
}
}
fn source_field(changed: &str) -> &str {
match changed {
"objective" => "notes",
"parent" | "order" => "outline",
"estimate" => "duration",
other => other,
}
}
fn assert_report_matches_mutation(current: &Plan, result: &ImportResult) {
for item in &result.report.items {
let Some(reference) = &item.work else {
continue;
};
let Some(before) = current.work_items.get(&reference.id) else {
assert!(item.changes.is_empty(), "{item:?}");
continue;
};
let after = &result.candidate.work_items[&reference.id];
assert_eq!(before == after, item.changes.is_empty(), "{item:?}");
assert_eq!(
item.outcome == ItemOutcome::Unchanged,
item.changes.is_empty(),
"{item:?}"
);
for change in &item.changes {
let field = source_field(&change.field);
let reported = item.preserved.iter().any(|p| p == field)
|| item.approximated.iter().any(|f| f.field == field);
assert!(reported, "{field} changed without a finding: {item:?}");
assert!(!item.kept.iter().any(|k| k == field), "{item:?}");
}
}
assert_links_match_mutation(current, result);
}
fn assert_links_match_mutation(current: &Plan, result: &ImportResult) {
for link in &result.report.links {
for id in &link.dependencies {
let after = result.candidate.find_dependency(*id).expect("dependency");
let changed = current.find_dependency(*id).is_some_and(|b| b != after);
let listed = link.changes.iter().any(|c| c.dependency == *id);
assert_eq!(changed, listed, "{link:?}");
if changed {
assert_ne!(link.outcome, LinkOutcome::Preserved, "{link:?}");
}
}
}
let imported: BTreeSet<WorkItemId> = result
.report
.items
.iter()
.filter_map(|i| i.work.as_ref().map(|w| w.id))
.collect();
for edge in ¤t.dependencies {
let owned = imported.contains(&edge.predecessor) && imported.contains(&edge.successor);
if owned && result.candidate.find_dependency(edge.id).is_none() {
assert!(
result
.report
.removed_dependencies
.iter()
.any(|r| r.id == edge.id),
"{edge:?}"
);
}
}
}
#[test]
fn every_report_describes_its_mutation() {
let edits: [fn(String) -> String; 10] = [
|xml| xml.replace(&link(3, 1, 1200, 6), &link(3, 1, 0, 6)),
|xml| xml.replace(&link(3, 1, 1200, 6), &link(3, 3, 1200, 6)),
|xml| xml.replace(&link(2, 1, 0, 7), ""),
|xml| xml.replace("<Priority>700</Priority>", ""),
|xml| xml.replace("<Priority>700</Priority>", "<Priority>650</Priority>"),
|xml| xml.replace("<Name>Spec</Name>", "<Name></Name>"),
|xml| xml.replace("<Milestone>1</Milestone>", ""),
|xml| xml.replace("<Notes>Write it</Notes>", "<Notes>Rewrite it</Notes>"),
|xml| xml.replace("PT16H0M0S", "PT0H0M0S").replace("PT8H", "PT9H"),
|xml| {
xml.replace(
"Approved</Name><OutlineLevel>2",
"Approved</Name><OutlineLevel>1",
)
},
];
for edit in edits {
let (current, result) = reimport(edit);
assert_report_matches_mutation(¤t, &result);
}
let fixtures = [
include_str!("../../../tests/mspdi/mpxj-release-plan.xml"),
include_str!("../../../tests/mspdi/mpxj-unsupported-features.xml"),
];
for xml in fixtures {
let current = import(&workspace(), xml).candidate;
assert_report_matches_mutation(¤t, &import(¤t, xml));
let edited = xml
.replace("<Priority>", "<Unused>")
.replace("</Priority>", "</Unused>");
assert_report_matches_mutation(¤t, &import(¤t, &edited));
}
}