tga 10.3.1

Developer productivity analytics — git commit collection, classification, and reporting
//! Unit tests for the PM work meaningfulness classifier (issue #3916).
//!
//! Why a separate file: keeps `mod.rs` and `extract.rs` under the 500-SLOC
//! production cap while covering every exclusion reason on a fixture.

use super::extract::{extract_fields, week_key};
use super::{
    classify, is_bot_account, thresholds, word_count, ExclusionReason, PmWorkInput, FORMULA_VERSION,
};

/// A JIRA REST payload as `work_items.raw_json` stores it.
fn jira_payload(reporter: &str, description: &str, created: &str) -> String {
    serde_json::json!({
        "fields": {
            "reporter": { "displayName": reporter },
            "description": description,
            "created": created,
        }
    })
    .to_string()
}

#[test]
fn classifier_reports_the_v1_formula_version() {
    assert_eq!(FORMULA_VERSION, "pm-work-1");
}

#[test]
fn exclusion_reason_round_trips_through_its_wire_string() {
    for reason in [
        ExclusionReason::None,
        ExclusionReason::TerseTitle,
        ExclusionReason::AutoGenerated,
        ExclusionReason::BotFiled,
    ] {
        let wire = reason.as_wire_str();
        assert_eq!(
            ExclusionReason::from_wire_str(wire),
            Some(reason),
            "{wire} must round-trip"
        );
    }
    assert_eq!(
        ExclusionReason::from_wire_str("SOMETHING_FROM_V2"),
        None,
        "an unknown reason must not be read as NONE"
    );
}

// --- TERSE_TITLE -----------------------------------------------------------

/// The Fabiana case from issue #3916: one-word sub-tasks counted identically
/// to substantive stories.
#[test]
fn terse_title_with_no_body_is_excluded() {
    for title in ["Final", "Phase 3", "Historical Occupancy"] {
        let verdict = classify(&PmWorkInput {
            title,
            description: None,
            reporter: Some("Fabiana Calabrese"),
            human_transitioned: false,
        });
        assert!(
            !verdict.is_meaningful,
            "{title:?} must not count as meaningful work"
        );
        assert_eq!(verdict.exclusion_reason, ExclusionReason::TerseTitle);
    }
}

#[test]
fn a_terse_title_with_a_description_body_is_meaningful() {
    let verdict = classify(&PmWorkInput {
        title: "Phase 3",
        description: Some("Roll the pricing model out to the EMEA portfolio."),
        reporter: Some("Fabiana Calabrese"),
        human_transitioned: false,
    });
    assert!(verdict.is_meaningful, "a real body rescues a terse title");
    assert_eq!(verdict.exclusion_reason, ExclusionReason::None);
    assert_eq!(verdict.body_word_count, 9);
}

#[test]
fn terse_title_boundary_is_the_documented_threshold() {
    let at_limit = "one two three four five";
    assert_eq!(word_count(at_limit), thresholds::TERSE_TITLE_MAX_WORDS);
    let over_limit = "one two three four five six";

    let terse = classify(&PmWorkInput {
        title: at_limit,
        ..PmWorkInput::default()
    });
    assert_eq!(terse.exclusion_reason, ExclusionReason::TerseTitle);

    let kept = classify(&PmWorkInput {
        title: over_limit,
        ..PmWorkInput::default()
    });
    assert_eq!(
        kept.exclusion_reason,
        ExclusionReason::None,
        "one word past the threshold must be kept"
    );
}

// --- AUTO_GENERATED / BOT_FILED -------------------------------------------

#[test]
fn bot_reporter_with_no_human_transition_is_auto_generated() {
    let verdict = classify(&PmWorkInput {
        title: "Bump serde from 1.0.203 to 1.0.204 in the workspace manifest",
        description: Some("Automated dependency update opened against main."),
        reporter: Some("dependabot[bot]"),
        human_transitioned: false,
    });
    assert!(!verdict.is_meaningful);
    assert_eq!(verdict.exclusion_reason, ExclusionReason::AutoGenerated);
}

#[test]
fn bot_reporter_with_human_transition_is_bot_filed() {
    let verdict = classify(&PmWorkInput {
        title: "Nightly integration suite failed on the pricing service",
        description: Some("Six assertions failed in the rate-shop regression pack."),
        reporter: Some("Jenkins Automation"),
        human_transitioned: true,
    });
    assert!(!verdict.is_meaningful);
    assert_eq!(
        verdict.exclusion_reason,
        ExclusionReason::BotFiled,
        "a human moving a bot-filed ticket is triage, not PM authorship"
    );
}

/// Provenance is checked before terseness, so a bot-filed stub reports the
/// more specific reason rather than TERSE_TITLE.
#[test]
fn bot_provenance_outranks_terseness() {
    let verdict = classify(&PmWorkInput {
        title: "Final",
        description: None,
        reporter: Some("automation-for-jira"),
        human_transitioned: false,
    });
    assert_eq!(verdict.exclusion_reason, ExclusionReason::AutoGenerated);
}

#[test]
fn bot_detection_matches_automation_accounts() {
    for name in [
        "dependabot[bot]",
        "renovate[bot]",
        "github-actions",
        "Jenkins Automation",
        "svc-jira-sync",
        "System",
        "Automation for Jira",
        "noreply@example.com",
        "release-ci",
        "deploy-ci",
        "ci-runner",
    ] {
        assert!(
            is_bot_account(name),
            "{name:?} must read as an automation account"
        );
    }
}

#[test]
fn bot_detection_does_not_misfire_on_human_names() {
    for name in [
        "Fabiana Calabrese",
        "Charles Abbott",
        "Talbot Reynolds",
        "Roberta Systems-Lee",
        "Bo Tran",
        "Lucia Moreau",
        "Marie-Cindy Okafor",
    ] {
        assert!(!is_bot_account(name), "{name:?} must read as a person");
    }
}

// --- meaningful ------------------------------------------------------------

/// Issue #3916 acceptance criterion: substantive stories with descriptions
/// over 20 words are meaningful.
#[test]
fn substantive_story_is_meaningful() {
    let description = "As a revenue manager I want the occupancy forecast to blend \
                       the last four comparable weeks so that a single anomalous \
                       weekend stops dominating next month's rate recommendations.";
    let verdict = classify(&PmWorkInput {
        title: "Blend the occupancy forecast across four comparable weeks",
        description: Some(description),
        reporter: Some("Fabiana Calabrese"),
        human_transitioned: true,
    });
    assert!(verdict.is_meaningful);
    assert_eq!(verdict.exclusion_reason, ExclusionReason::None);
    assert!(
        verdict.body_word_count > 20,
        "fixture must exercise the >20-word case, got {}",
        verdict.body_word_count
    );
    assert_eq!(verdict.title_word_count, 8);
}

#[test]
fn a_missing_reporter_is_treated_as_human() {
    let verdict = classify(&PmWorkInput {
        title: "Reconcile the group-booking pace report with the PMS extract",
        description: Some("The two disagree by roughly 3% on shoulder dates."),
        reporter: None,
        human_transitioned: false,
    });
    assert!(
        verdict.is_meaningful,
        "an absent reporter is a payload gap, not evidence of automation"
    );
}

// --- extraction ------------------------------------------------------------

#[test]
fn extracts_jira_reporter_description_and_created() {
    let raw = jira_payload(
        "Fabiana Calabrese",
        "Blend the last four comparable weeks.",
        "2026-01-15T09:30:00.000+0000",
    );
    let fields = extract_fields(Some(&raw));
    assert_eq!(fields.reporter.as_deref(), Some("Fabiana Calabrese"));
    assert_eq!(
        fields.description.as_deref(),
        Some("Blend the last four comparable weeks.")
    );
    assert_eq!(
        fields.created.map(week_key).as_deref(),
        Some("2026-W03"),
        "JIRA's colon-less offset must parse"
    );
}

#[test]
fn flattens_atlassian_document_format_description() {
    let raw = serde_json::json!({
        "fields": {
            "reporter": { "displayName": "Fabiana Calabrese" },
            "description": {
                "type": "doc",
                "version": 1,
                "content": [{
                    "type": "paragraph",
                    "content": [
                        { "type": "text", "text": "Blend the forecast" },
                        { "type": "text", "text": "across four weeks." }
                    ]
                }]
            }
        }
    })
    .to_string();
    let fields = extract_fields(Some(&raw));
    assert_eq!(
        fields.description.as_deref(),
        Some("Blend the forecast across four weeks."),
        "ADF text leaves must flatten to prose"
    );
}

#[test]
fn strips_html_from_an_azure_devops_description() {
    let raw = serde_json::json!({
        "fields": {
            "System.CreatedBy": { "displayName": "Charles Abbott" },
            "System.Description": "<div><p>Reconcile</p><p>the pace report</p></div>",
            "System.CreatedDate": "2026-01-15T09:30:00Z",
        }
    })
    .to_string();
    let fields = extract_fields(Some(&raw));
    assert_eq!(fields.reporter.as_deref(), Some("Charles Abbott"));
    assert_eq!(
        fields.description.as_deref().map(word_count),
        Some(4),
        "HTML tags must not fuse or inflate the word count"
    );
}

#[test]
fn extracts_github_body_and_login() {
    let raw = serde_json::json!({
        "body": "The rate-shop worker retries forever on a 429.",
        "user": { "login": "dependabot[bot]" },
        "created_at": "2026-01-15T09:30:00Z",
    })
    .to_string();
    let fields = extract_fields(Some(&raw));
    assert_eq!(fields.reporter.as_deref(), Some("dependabot[bot]"));
    assert_eq!(fields.created.map(week_key).as_deref(), Some("2026-W03"));
    assert!(fields.description.is_some());
}

#[test]
fn unparseable_payload_yields_no_fields() {
    for raw in [None, Some("not json"), Some("{}")] {
        let fields = extract_fields(raw);
        assert_eq!(fields.reporter, None);
        assert_eq!(fields.description, None);
        assert_eq!(fields.created, None);
    }
}

/// A December 31st that ISO-8601 assigns to the NEXT year's week 1: using the
/// calendar year here would file it under `2024-W01` and split the week.
#[test]
fn week_key_uses_the_iso_week_numbering_year() {
    let at = chrono::DateTime::parse_from_rfc3339("2024-12-31T12:00:00Z")
        .expect("parse")
        .with_timezone(&chrono::Utc);
    assert_eq!(week_key(at), "2025-W01");
}