dpm-model 0.1.0

Domain model for DPM execution planning
Documentation
use crate::*;

fn fixture() -> Plan {
    serde_json::from_str(include_str!(
        "../../../../tests/support/execution-plan.json"
    ))
    .expect("fixture")
}

#[test]
fn fixture_and_empty_workspace_are_valid() {
    fixture().validate().expect("valid fixture");
    Plan::empty("empty")
        .validate()
        .expect("valid empty workspace");
}

#[test]
fn decision_context_defaults_preserve_existing_serialized_gates() {
    let source: serde_json::Value = serde_json::from_str(include_str!(
        "../../../../tests/support/execution-plan.json"
    ))
    .expect("source");
    let plan: Plan = serde_json::from_value(source.clone()).expect("compatible plan");
    for (id, decision) in &plan.decisions {
        assert!(decision.related_work.is_empty() && decision.artifact_ids.is_empty());
        assert!(decision.rationale.is_none());
        assert_eq!(
            serde_json::to_value(decision).expect("serialize"),
            source["decisions"][id.to_string()]
        );
    }
}

#[test]
fn decision_context_rejects_dangling_references_and_empty_reasons() {
    let cases: Vec<fn(&mut Decision)> = vec![
        |d| {
            d.related_work.insert(WorkItemId::new());
        },
        |d| {
            d.artifact_ids.insert(ArtifactId::new());
        },
        |d| {
            d.rationale = Some("  ".into());
        },
    ];
    for corrupt in cases {
        let mut plan = fixture();
        corrupt(plan.decisions.values_mut().next().expect("decision"));
        assert!(plan.validate().is_err());
    }
}

#[test]
fn malformed_graphs_and_contracts_are_rejected() {
    let cases: Vec<fn(&mut Plan)> = vec![
        |p| p.workspace.name.clear(),
        |p| p.work_items.values_mut().next().expect("work").id = WorkItemId::new(),
        |p| p.work_items.values_mut().next().expect("work").project = ProjectId::new(),
        |p| {
            p.work_items
                .values_mut()
                .for_each(|w| w.key = Key::new("duplicate"))
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .contract
                .objective
                .clear()
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .contract
                .acceptance
                .clear()
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .contract
                .acceptance[0]
                .text = " ".into()
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .execution
                .owner = Some(ActorId::agent("owner"))
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .execution
                .status = WorkStatus::Claimed
        },
        |p| {
            p.find_work_by_key_mut("TEST-A")
                .expect("task")
                .execution
                .artifact_ids
                .insert(ArtifactId::new());
        },
        |p| {
            p.decisions
                .values_mut()
                .next()
                .expect("gate")
                .blocks
                .insert(WorkItemId::new());
        },
        |p| p.dependencies[0].predecessor = WorkItemId::new(),
        |p| p.dependencies[0].lag_hours = f64::NAN,
        |p| p.dependencies[0].successor = p.dependencies[0].predecessor,
        |p| {
            let project = p.projects.values_mut().next().expect("project");
            project.parent = Some(project.id);
        },
        |p| {
            let task = p.find_work_by_key_mut("TEST-A").expect("task");
            task.parent = Some(task.id);
        },
    ];
    for (index, mutate) in cases.into_iter().enumerate() {
        let mut plan = fixture();
        mutate(&mut plan);
        assert!(plan.validate().is_err(), "invalid case {index} accepted");
    }
}

#[test]
fn estimates_reject_nonfinite_negative_and_unordered_bounds() {
    for estimate in [
        ThreePointEstimate {
            optimistic_hours: f64::NAN,
            likely_hours: 1.0,
            pessimistic_hours: 2.0,
        },
        ThreePointEstimate {
            optimistic_hours: -1.0,
            likely_hours: 1.0,
            pessimistic_hours: 2.0,
        },
        ThreePointEstimate {
            optimistic_hours: 2.0,
            likely_hours: 1.0,
            pessimistic_hours: 3.0,
        },
        ThreePointEstimate {
            optimistic_hours: 1.0,
            likely_hours: 2.0,
            pessimistic_hours: f64::INFINITY,
        },
    ] {
        let mut plan = fixture();
        plan.find_work_by_key_mut("TEST-A")
            .expect("task")
            .schedule
            .estimate = Some(estimate);
        assert!(matches!(
            plan.validate(),
            Err(ValidationError::Estimate { .. })
        ));
    }
}

#[test]
fn large_finite_estimates_do_not_overflow_the_weighted_mean() {
    let estimate = ThreePointEstimate {
        optimistic_hours: f64::MAX,
        likely_hours: f64::MAX,
        pessimistic_hours: f64::MAX,
    };
    estimate.validate().expect("finite ordered bounds");
    assert!(estimate.pert_expected_hours().is_finite());
}

#[test]
fn unknown_plan_fields_are_rejected_instead_of_dropping_proposed_edits() {
    let source = serde_json::to_value(fixture()).expect("serialize");
    let work = fixture().find_work_by_key("TEST-A").expect("task").id;
    for path in [
        String::new(),
        "/workspace".into(),
        format!("/work_items/{work}"),
        format!("/work_items/{work}/contract"),
        format!("/work_items/{work}/execution"),
        format!("/work_items/{work}/schedule"),
        format!("/work_items/{work}/contract/acceptance/0"),
    ] {
        let mut value = source.clone();
        value
            .pointer_mut(&path)
            .expect("object")
            .as_object_mut()
            .expect("map")
            .insert("unrecognized_edit".into(), serde_json::json!(true));
        let error =
            serde_json::from_value::<Plan>(value).expect_err("unknown edit must not disappear");
        assert!(error.to_string().contains("unknown field"));
    }
}

#[test]
fn decision_replacement_links_must_reach_a_superseded_decision_without_loops() {
    let mut plan = fixture();
    let old = plan.decisions.values().next().expect("decision").clone();
    let mut replacement = old.clone();
    replacement.id = DecisionId::new();
    replacement.key = Key::new("TEST-GATE-2");
    replacement.status = DecisionStatus::Decided;
    replacement.outcome = Some("Use the revised input".into());
    replacement.blocks.clear();
    replacement.supersedes = Some(old.id);
    plan.decisions.insert(replacement.id, replacement.clone());
    assert!(plan.validate().is_err(), "target is still Open");
    plan.decisions.get_mut(&old.id).expect("old").status = DecisionStatus::Superseded;
    plan.validate().expect("linked replacement");
    let mut duplicate = replacement.clone();
    duplicate.id = DecisionId::new();
    duplicate.key = Key::new("TEST-GATE-3");
    plan.decisions.insert(duplicate.id, duplicate.clone());
    assert!(plan.validate().is_err(), "one replacement per decision");
    plan.decisions.remove(&duplicate.id);
    let (first, second) = (old.id, replacement.id);
    let edited = plan.decisions.get_mut(&second).expect("new");
    edited.supersedes = Some(DecisionId::new());
    assert!(plan.validate().is_err(), "dangling replacement");
    let edited = plan.decisions.get_mut(&second).expect("new");
    edited.supersedes = Some(second);
    assert!(plan.validate().is_err(), "self replacement");
    let edited = plan.decisions.get_mut(&second).expect("new");
    edited.status = DecisionStatus::Superseded;
    edited.supersedes = Some(first);
    plan.decisions.get_mut(&first).expect("old").supersedes = Some(second);
    assert!(plan.validate().is_err(), "replacement cycle");
}

#[test]
fn an_unrecorded_start_marker_needs_started_work_without_a_start_time() {
    let mut plan = fixture();
    let id = plan.find_work_by_key("TEST-A").expect("task").id;
    let work = plan.work_items.get_mut(&id).expect("task");
    work.execution.owner = Some(ActorId::agent("legacy"));
    work.execution.status = WorkStatus::Blocked;
    work.execution.block_reason = Some("vendor".into());
    work.execution.events.start_unrecorded = true;
    plan.validate().expect("a blocked legacy start is valid");
    let serialized = serde_json::to_value(plan.work_items[&id].execution.events).expect("events");
    assert_eq!(serialized, serde_json::json!({"start_unrecorded": true}));
    let mut timed = plan.clone();
    timed
        .work_items
        .get_mut(&id)
        .expect("task")
        .execution
        .events
        .started_at = Some(chrono::Utc::now());
    assert!(
        timed.validate().is_err(),
        "a recorded start is not unrecorded"
    );
    let work = plan.work_items.get_mut(&id).expect("task");
    work.execution.status = WorkStatus::Claimed;
    work.execution.block_reason = None;
    assert!(plan.validate().is_err(), "claimed work has not started");
    assert_eq!(
        serde_json::to_value(ExecutionEvents::default()).expect("events"),
        serde_json::json!({}),
        "the marker is additive and absent by default"
    );
}

#[test]
fn handoffs_name_two_owners_a_reason_and_keep_time_order() {
    let base = fixture();
    let task = base.find_work_by_key("TEST-A").expect("task").id;
    let milestone = base.find_work_by_key("TEST-M1").expect("milestone").id;
    let at = chrono::DateTime::<chrono::Utc>::UNIX_EPOCH;
    let handoff = |from: &str, to: &str, reason: &str, hours: i64| Handoff {
        from: ActorId::agent(from),
        to: ActorId::agent(to),
        actor: ActorId::human("lead"),
        at: at + chrono::TimeDelta::hours(hours),
        reason: reason.into(),
    };
    let mut valid = base.clone();
    let item = valid.work_items.get_mut(&task).expect("task");
    item.execution.handoffs = vec![handoff("a", "b", "moved", 1), handoff("b", "a", "back", 1)];
    valid.validate().expect("ordered handoffs");
    let invalid = [
        (task, vec![handoff("a", "a", "same", 1)]),
        (task, vec![handoff("a", "b", " ", 1)]),
        (
            task,
            vec![handoff("a", "b", "x", 2), handoff("b", "c", "y", 1)],
        ),
        (milestone, vec![handoff("a", "b", "not a task", 1)]),
    ];
    for (work, handoffs) in invalid {
        let mut plan = base.clone();
        plan.work_items
            .get_mut(&work)
            .expect("work")
            .execution
            .handoffs = handoffs;
        assert!(plan.validate().is_err());
    }
}

#[test]
fn actors_parse_from_the_adapter_spelling_and_round_trip() {
    for spelling in ["human:ann", "agent:coder", "service:ci"] {
        let actor: ActorId = spelling.parse().expect("actor");
        assert_eq!(actor.to_string(), spelling);
    }
    assert_eq!(
        "coder".parse::<ActorId>(),
        Err(ActorParseError::MissingKind("coder".into()))
    );
    assert_eq!(
        "robot:x".parse::<ActorId>(),
        Err(ActorParseError::UnknownKind("robot".into()))
    );
    assert_eq!(
        "agent: ".parse::<ActorId>(),
        Err(ActorParseError::EmptyName)
    );
}