xbp-analytics 11.1.7

Deterministic delivery analytics primitives for XBP.
Documentation
use chrono::{Duration, Utc};
use xbp_analytics::{CoverageAvailability, FlowProjection, TimeWindow};
use xbp_delivery::{DeliveryEvent, DeliveryEventKind, SourceSystem, WorkItemId};

fn event(
    id: &str,
    kind: DeliveryEventKind,
    work_item: &str,
    occurred_at: chrono::DateTime<Utc>,
) -> DeliveryEvent {
    let mut event = DeliveryEvent::new(id, kind, SourceSystem::Xbp, id);
    event.work_item_id = Some(WorkItemId(work_item.into()));
    event.occurred_at = occurred_at;
    event
}

#[test]
fn flow_completion_cohort_reconstructs_lifecycle_before_the_window() {
    let now = Utc::now();
    let events = vec![
        event(
            "created",
            DeliveryEventKind::WorkItemCreated,
            "item",
            now - Duration::days(2),
        ),
        event(
            "started",
            DeliveryEventKind::WorkItemStarted,
            "item",
            now - Duration::days(1),
        ),
        event(
            "completed",
            DeliveryEventKind::WorkItemCompleted,
            "item",
            now,
        ),
    ];

    let projection =
        FlowProjection::from_events(&events, TimeWindow::new(now - Duration::hours(12), now))
            .expect("flow projection");

    assert_eq!(projection.completed_count, 1);
    assert_eq!(projection.coverage.eligible, 1);
    assert_eq!(projection.coverage.observed, 1);
}

#[test]
fn flow_coverage_keeps_completed_work_with_missing_lifecycle_evidence() {
    let now = Utc::now();
    let events = vec![event(
        "completed",
        DeliveryEventKind::WorkItemCompleted,
        "item",
        now,
    )];

    let projection =
        FlowProjection::from_events(&events, TimeWindow::new(now - Duration::hours(12), now))
            .expect("flow projection");

    assert_eq!(projection.coverage.eligible, 1);
    assert_eq!(projection.coverage.observed, 0);
    assert_eq!(projection.coverage.sufficient, 0);
    assert_eq!(
        projection.coverage.availability,
        CoverageAvailability::Unavailable
    );
}

#[test]
fn flow_reconstructs_multiple_block_intervals_and_current_blocked_wip() {
    let now = Utc::now();
    let events = vec![
        event(
            "created",
            DeliveryEventKind::WorkItemCreated,
            "done",
            now - Duration::days(2),
        ),
        event(
            "started",
            DeliveryEventKind::WorkItemStarted,
            "done",
            now - Duration::days(1),
        ),
        event(
            "blocked-1",
            DeliveryEventKind::WorkItemBlocked,
            "done",
            now - Duration::hours(20),
        ),
        event(
            "unblocked-1",
            DeliveryEventKind::WorkItemUnblocked,
            "done",
            now - Duration::hours(18),
        ),
        event(
            "blocked-2",
            DeliveryEventKind::WorkItemBlocked,
            "done",
            now - Duration::hours(10),
        ),
        event(
            "unblocked-2",
            DeliveryEventKind::WorkItemUnblocked,
            "done",
            now - Duration::hours(9),
        ),
        event(
            "completed",
            DeliveryEventKind::WorkItemCompleted,
            "done",
            now,
        ),
        event(
            "wip-created",
            DeliveryEventKind::WorkItemCreated,
            "wip",
            now - Duration::hours(4),
        ),
        event(
            "wip-blocked",
            DeliveryEventKind::WorkItemBlocked,
            "wip",
            now - Duration::hours(2),
        ),
    ];

    let projection =
        FlowProjection::from_events(&events, TimeWindow::new(now - Duration::days(3), now))
            .expect("flow projection");

    assert_eq!(projection.blocked_wip, 1);
    assert_eq!(projection.blocked_time_hours.mean, Some(3.0));
}

#[test]
fn flow_rejects_invalid_block_and_lifecycle_transitions() {
    let now = Utc::now();
    let unmatched_unblock = vec![event(
        "unblocked",
        DeliveryEventKind::WorkItemUnblocked,
        "item",
        now,
    )];
    assert!(FlowProjection::from_events(
        &unmatched_unblock,
        TimeWindow::new(now - Duration::hours(1), now),
    )
    .is_err());

    let completion_before_start = vec![
        event(
            "created",
            DeliveryEventKind::WorkItemCreated,
            "item",
            now - Duration::hours(2),
        ),
        event("started", DeliveryEventKind::WorkItemStarted, "item", now),
        event(
            "completed",
            DeliveryEventKind::WorkItemCompleted,
            "item",
            now - Duration::hours(1),
        ),
    ];
    assert!(FlowProjection::from_events(
        &completion_before_start,
        TimeWindow::new(now - Duration::days(1), now),
    )
    .is_err());
}

#[test]
fn flow_v1_projection_preserves_legacy_metric_names() {
    let now = Utc::now();
    let events = vec![
        event(
            "created",
            DeliveryEventKind::WorkItemCreated,
            "item",
            now - Duration::hours(2),
        ),
        event(
            "started",
            DeliveryEventKind::WorkItemStarted,
            "item",
            now - Duration::hours(1),
        ),
        event(
            "completed",
            DeliveryEventKind::WorkItemCompleted,
            "item",
            now,
        ),
    ];
    let projection =
        FlowProjection::from_events(&events, TimeWindow::new(now - Duration::days(1), now))
            .expect("flow projection");

    let legacy = projection.to_v1();

    assert_eq!(legacy.wip, 0);
    assert!(legacy.lead_time.is_some());
}