tga 5.0.2

Developer productivity analytics — git commit collection, classification, and reporting
Documentation
//! Tests for profile report rendering and file output.
//!
//! Why: split out of `reporter.rs` so that file stays well under the production
//! SLOC cap while the coverage stays whole.
//! What: covers JSON round-tripping through the filesystem, every Markdown
//! section, the pipe-escaping guard, format parsing, and stem safety.
//! Test: included as `#[cfg(test)] mod tests` from `reporter.rs`.
//!
//! One test from the trusty-review original is deliberately absent: the GitHub
//! issue request construction belongs with the GitHub write path, in #5465.

use super::{profile_file_stem, render_markdown, ReportFormat, Reporter};
use crate::profile::types::{
    ContributorProfile, Effort, Finding, LongitudinalFinding, TokenCostSummary, Trajectory,
    TrendTag,
};

fn make_profile() -> ContributorProfile {
    let mut p = ContributorProfile::new(
        "alice@example.com",
        "Alice Smith",
        "2026-01-01",
        "2026-06-30",
    );
    p.repositories = vec!["acme/api".to_string()];
    p.improvement_trajectory = Trajectory::Improving;
    p.quality_trend = vec![("2026-Q1".to_string(), 3.0), ("2026-Q2".to_string(), 3.8)];
    p.strengths = vec!["Consistent ticket coverage".to_string()];
    p.recurring_weaknesses = vec!["Missing error handling".to_string()];
    p.all_findings = vec![LongitudinalFinding {
        period_label: "2026-Q1".to_string(),
        finding: Finding::new(
            "src/lib.rs",
            "error_handling",
            "Missing propagation",
            "Use ?",
            0.8,
            Effort::Medium,
        ),
        trend_tag: Some(TrendTag::Recurring),
    }];
    p.narrative = "Alice shows strong improvement.".to_string();
    p.token_cost = TokenCostSummary {
        input_tokens: 500,
        output_tokens: 200,
        cost_usd: 0.005,
        latency_ms: 1500,
    };
    p
}

/// Why: the JSON artefact is read back by later runs, so it must survive a
/// write-then-parse cycle, not merely serialise.
/// What: writes into a temp directory, reads the file, deserialises, asserts the
/// identity survived.
/// Test: this test itself.
#[test]
fn reporter_json_output() {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    let reporter = Reporter::new(tmp.path(), ReportFormat::Json);
    let profile = make_profile();

    let paths = reporter.write_profile(&profile).expect("write_profile");
    assert_eq!(paths.len(), 1);
    assert!(paths[0].extension().is_some_and(|e| e == "json"));

    let content = std::fs::read_to_string(&paths[0]).expect("read");
    let back: ContributorProfile = serde_json::from_str(&content).expect("parse");
    assert_eq!(back.canonical_email, "alice@example.com");
    assert_eq!(back.all_findings.len(), 1);
    assert_eq!(back.all_findings[0].finding.effort, Effort::Medium);
}

/// Why: the Markdown report is the human-facing contract, so every section it
/// promises must actually render.
/// What: renders a fully-populated profile and asserts each section heading and
/// a representative value from each.
/// Test: this test itself.
#[test]
fn reporter_markdown_contains_sections() {
    let md = render_markdown(&make_profile());

    assert!(md.contains("# Developer Profile: Alice Smith"), "header");
    assert!(md.contains("## Quality Trend"), "quality trend section");
    assert!(md.contains("2026-Q1"), "period label");
    assert!(md.contains("## Strengths"), "strengths section");
    assert!(
        md.contains("Consistent ticket coverage"),
        "strength content"
    );
    assert!(
        md.contains("## Areas for Improvement"),
        "weaknesses section"
    );
    assert!(md.contains("## Findings"), "findings section");
    assert!(md.contains("error_handling"), "finding kind");
    assert!(md.contains("Recurring"), "trend tag");
    assert!(
        md.contains("## Engineering Assessment"),
        "narrative section"
    );
    assert!(
        md.contains("Alice shows strong improvement"),
        "narrative content"
    );
    assert!(md.contains("## Token & Cost Summary"), "cost section");
    assert!(md.contains("500"), "input tokens");
    assert!(md.contains("Generated by tga"), "footer names the producer");
}

/// Why: a `|` in a description would split the table row and corrupt every
/// column after it, and descriptions come from model output.
/// What: renders a finding whose description contains a pipe, asserts it was
/// escaped.
/// Test: this test itself.
#[test]
fn reporter_markdown_escapes_pipes_in_descriptions() {
    let mut profile = make_profile();
    profile.all_findings[0].finding.description = "matches a | b in the parser".to_string();
    let md = render_markdown(&profile);
    assert!(
        md.contains("matches a \\| b in the parser"),
        "a pipe in a description must be escaped: {md}"
    );
}

/// Why: `Both` is the default a caller gets, so it must actually produce both
/// files rather than silently one.
/// What: writes with `Both`, asserts two paths, one `.json` and one `.md`.
/// Test: this test itself.
#[test]
fn reporter_both_format_writes_two_files() {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    let reporter = Reporter::new(tmp.path(), ReportFormat::Both);

    let paths = reporter
        .write_profile(&make_profile())
        .expect("write_profile");
    assert_eq!(paths.len(), 2, "should write 2 files in Both mode");
    assert!(
        paths
            .iter()
            .any(|p| p.extension().is_some_and(|e| e == "json")),
        "should include JSON file"
    );
    assert!(
        paths
            .iter()
            .any(|p| p.extension().is_some_and(|e| e == "md")),
        "should include Markdown file"
    );
}

/// Why: the stem is built from an email address, which carries `@` and `.` —
/// characters that make for awkward filenames.
/// What: asserts the stem is prefixed and carries no `@`.
/// Test: this test itself.
#[test]
fn profile_file_stem_safe() {
    let stem = profile_file_stem(&make_profile());
    assert!(!stem.contains('@'), "stem must not contain @: {stem}");
    assert!(
        stem.starts_with("profile_"),
        "stem must start with profile_: {stem}"
    );
}

/// Why: the format arrives as a user-supplied string, so every accepted
/// spelling and the rejection must be pinned.
/// What: parses each variant plus the `md` alias, and asserts `xml` errors.
/// Test: this test itself.
#[test]
fn report_format_from_str() {
    use std::str::FromStr;
    assert_eq!(
        ReportFormat::from_str("json").expect("json"),
        ReportFormat::Json
    );
    assert_eq!(
        ReportFormat::from_str("markdown").expect("markdown"),
        ReportFormat::Markdown
    );
    assert_eq!(
        ReportFormat::from_str("both").expect("both"),
        ReportFormat::Both
    );
    assert_eq!(
        ReportFormat::from_str("md").expect("md"),
        ReportFormat::Markdown
    );
    assert!(ReportFormat::from_str("xml").is_err());
}

/// Why: a deterministic-only run spends nothing, and printing a zeroed cost
/// table would suggest a narrative pass ran and cost nothing.
/// What: zeroes `token_cost`, asserts the section is absent.
/// Test: this test itself.
#[test]
fn reporter_markdown_no_cost_section_when_zero() {
    let mut profile = make_profile();
    profile.token_cost = TokenCostSummary::default();
    let md = render_markdown(&profile);
    assert!(
        !md.contains("## Token & Cost Summary"),
        "zero cost must omit the cost section"
    );
}

// ─── Coverage note (#5465) ────────────────────────────────────────────────────

/// Why: a run where the provider failed on some periods writes a report that
/// looks complete — same sections, just fewer findings. The coverage note is the
/// only thing in the file that tells a reader the trajectory covers a smaller
/// sample, so `render` dropping it reinstates exactly the silence #5464 closed,
/// one layer downstream.
/// What: renders with and without a note, asserting the note appears above the
/// first section, that the profile body survives intact, and that a reporter
/// with no note renders byte-identically to `render_markdown`.
/// Test: this test itself.
#[test]
fn reporter_markdown_carries_the_coverage_note() {
    let profile = make_profile();
    let plain = Reporter::new("/tmp/unused", ReportFormat::Markdown);
    assert_eq!(
        plain.render(&profile),
        render_markdown(&profile),
        "with no note, render must be exactly the plain markdown"
    );

    let noted = Reporter::new("/tmp/unused", ReportFormat::Markdown)
        .with_coverage_note("## Coverage\n\n1 of 2 period(s) reviewed; `2026-Q2` skipped.\n");
    let out = noted.render(&profile);

    assert!(
        out.contains("2026-Q2` skipped"),
        "the coverage note must reach the rendered report: {out}"
    );
    let note_at = out.find("## Coverage").expect("note present");
    let trend_at = out.find("## Quality Trend").expect("trend section present");
    assert!(
        note_at < trend_at,
        "the note must sit above the trend it qualifies (note {note_at}, trend {trend_at})"
    );
    assert!(
        out.contains("# Developer Profile: Alice Smith"),
        "the header must survive the splice: {out}"
    );
    assert!(
        out.contains("Consistent ticket coverage"),
        "the body must survive the splice: {out}"
    );
}