datasynth-generators 2.4.0

50+ data generators covering GL, P2P, O2C, S2C, HR, manufacturing, audit, tax, treasury, and ESG
Documentation
//! Integration tests for the ISA 600 component audit generator.
//!
//! Validates:
//! - Single-entity: 1 auditor, 1 instruction, 1 report
//! - Multi-entity with 2 jurisdictions: 2 auditors
//! - Scope assignment follows share thresholds
//! - Sum of component materialities ≤ group materiality
//! - Every entity covered by exactly one instruction
//! - All reports reference valid instruction IDs

use std::collections::HashSet;

use chrono::NaiveDate;
use datasynth_config::schema::{CompanyConfig, TransactionVolume};
use datasynth_core::models::audit::component_audit::ComponentScope;
use datasynth_generators::audit::component_audit_generator::ComponentAuditGenerator;
use rust_decimal::Decimal;

fn make_company(code: &str, name: &str, country: &str) -> CompanyConfig {
    CompanyConfig {
        code: code.to_string(),
        name: name.to_string(),
        currency: "USD".to_string(),
        functional_currency: None,
        country: country.to_string(),
        fiscal_year_variant: "K4".to_string(),
        annual_transaction_volume: TransactionVolume::TenK,
        volume_weight: 1.0,
    }
}

fn make_company_weighted(
    code: &str,
    name: &str,
    country: &str,
    volume_weight: f64,
) -> CompanyConfig {
    CompanyConfig {
        code: code.to_string(),
        name: name.to_string(),
        currency: "USD".to_string(),
        functional_currency: None,
        country: country.to_string(),
        fiscal_year_variant: "K4".to_string(),
        annual_transaction_volume: TransactionVolume::TenK,
        volume_weight,
    }
}

fn period_end() -> NaiveDate {
    NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()
}

// =============================================================================
// Single entity
// =============================================================================

#[test]
fn test_single_entity_produces_correct_counts() {
    let companies = vec![make_company("C001", "Solo Corp", "US")];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(1_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-001", period_end());

    assert_eq!(
        snapshot.component_auditors.len(),
        1,
        "1 auditor for 1 jurisdiction"
    );
    assert_eq!(
        snapshot.component_instructions.len(),
        1,
        "1 instruction for 1 entity"
    );
    assert_eq!(snapshot.component_reports.len(), 1, "1 report for 1 entity");
    assert!(
        snapshot.group_audit_plan.is_some(),
        "group plan must be present"
    );
}

// =============================================================================
// Multi-entity, two jurisdictions
// =============================================================================

#[test]
fn test_two_jurisdictions_produce_two_auditors() {
    let companies = vec![
        make_company("C001", "Alpha Inc", "US"),
        make_company("C002", "Beta GmbH", "DE"),
        make_company("C003", "Gamma LLC", "US"), // same jurisdiction as C001
    ];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(5_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-002", period_end());

    assert_eq!(
        snapshot.component_auditors.len(),
        2,
        "US and DE should produce exactly 2 component auditors"
    );
    assert_eq!(snapshot.component_instructions.len(), 3);
    assert_eq!(snapshot.component_reports.len(), 3);
}

// =============================================================================
// Scope assignment thresholds
// =============================================================================

#[test]
fn test_large_entity_gets_full_scope() {
    // 2 companies with explicit volume weights: C001=3.0, C002=1.0
    // C001 share = 3.0/4.0 = 75% → FullScope
    let companies = vec![
        make_company_weighted("C001", "BigCo", "US", 3.0),
        make_company_weighted("C002", "TinyCo", "US", 1.0),
    ];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(10_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-003", period_end());

    let c001_inst = snapshot
        .component_instructions
        .iter()
        .find(|i| i.entity_code == "C001")
        .expect("C001 instruction missing");

    assert_eq!(
        c001_inst.scope,
        ComponentScope::FullScope,
        "C001 at 75% should be FullScope"
    );
}

#[test]
fn test_medium_entity_gets_specific_scope() {
    // Use explicit volume_weight values so C004 lands in the 5–15% band.
    // Total weight = 3.0+2.0+1.5+0.8+0.2 = 7.5
    // C004 (0.8): share = 0.8/7.5 ≈ 10.7% → SpecificScope (5–15%)
    let weights = [3.0f64, 2.0, 1.5, 0.8, 0.2];
    let companies: Vec<CompanyConfig> = weights
        .iter()
        .enumerate()
        .map(|(i, &w)| {
            make_company_weighted(
                &format!("C{:03}", i + 1),
                &format!("Company {}", i + 1),
                "US",
                w,
            )
        })
        .collect();
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(10_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-004", period_end());

    let c004_inst = snapshot
        .component_instructions
        .iter()
        .find(|i| i.entity_code == "C004")
        .expect("C004 instruction missing");

    // C004 share ≈ 10.7% → SpecificScope (≥5% but <15%)
    assert!(
        matches!(c004_inst.scope, ComponentScope::SpecificScope { .. }),
        "C004 at ~10.7% should be SpecificScope, got {:?}",
        c004_inst.scope
    );
}

#[test]
fn test_small_entity_gets_analytical_only() {
    // One dominant entity (weight=10.0) and a tiny entity (weight=0.4).
    // Tiny share = 0.4/10.4 ≈ 3.8% → AnalyticalOnly (< 5%)
    let companies = vec![
        make_company_weighted("C001", "DominantCo", "US", 10.0),
        make_company_weighted("C002", "TinyCo", "US", 0.4),
    ];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(10_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-005", period_end());

    let c002_inst = snapshot
        .component_instructions
        .iter()
        .find(|i| i.entity_code == "C002")
        .expect("C002 instruction missing");

    assert_eq!(
        c002_inst.scope,
        ComponentScope::AnalyticalOnly,
        "C002 at ~3.8% should be AnalyticalOnly"
    );
}

// =============================================================================
// Materiality constraints
// =============================================================================

#[test]
fn test_sum_of_component_materialities_le_group_materiality() {
    let companies: Vec<CompanyConfig> = (1..=6)
        .map(|i| make_company(&format!("C{i:03}"), &format!("Firm {i}"), "US"))
        .collect();
    let mut gen = ComponentAuditGenerator::new(99);
    let group_mat = Decimal::new(3_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-006", period_end());

    let plan = snapshot.group_audit_plan.as_ref().unwrap();
    let sum: Decimal = plan
        .component_allocations
        .iter()
        .map(|a| a.component_materiality)
        .sum();

    assert!(
        sum <= group_mat,
        "Sum of component materialities ({sum}) must not exceed group materiality ({group_mat})"
    );
}

#[test]
fn test_clearly_trivial_is_fraction_of_component_materiality() {
    let companies = vec![make_company("C001", "Alpha", "US")];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(2_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-007", period_end());

    let plan = snapshot.group_audit_plan.as_ref().unwrap();
    let alloc = &plan.component_allocations[0];

    assert!(
        alloc.clearly_trivial < alloc.component_materiality,
        "Clearly trivial ({}) must be less than component materiality ({})",
        alloc.clearly_trivial,
        alloc.component_materiality
    );
}

// =============================================================================
// Instruction and report linkage
// =============================================================================

#[test]
fn test_all_entities_covered_by_exactly_one_instruction() {
    let companies = vec![
        make_company("C001", "Alpha", "US"),
        make_company("C002", "Beta", "DE"),
        make_company("C003", "Gamma", "FR"),
        make_company("C004", "Delta", "US"),
    ];
    let mut gen = ComponentAuditGenerator::new(7);
    let group_mat = Decimal::new(4_000_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-008", period_end());

    for company in &companies {
        let count = snapshot
            .component_instructions
            .iter()
            .filter(|i| i.entity_code == company.code)
            .count();
        assert_eq!(
            count, 1,
            "Entity {} should have exactly 1 instruction, found {count}",
            company.code
        );
    }
}

#[test]
fn test_all_reports_reference_valid_instruction_ids() {
    let companies = vec![
        make_company("C001", "Alpha", "US"),
        make_company("C002", "Beta", "GB"),
        make_company("C003", "Gamma", "AU"),
    ];
    let mut gen = ComponentAuditGenerator::new(123);
    let group_mat = Decimal::new(1_500_000, 0);

    let snapshot = gen.generate(&companies, group_mat, "ENG-009", period_end());

    let instruction_ids: HashSet<String> = snapshot
        .component_instructions
        .iter()
        .map(|i| i.id.clone())
        .collect();

    for report in &snapshot.component_reports {
        assert!(
            instruction_ids.contains(&report.instruction_id),
            "Report {} references unknown instruction ID '{}'",
            report.id,
            report.instruction_id
        );
    }
}

#[test]
fn test_reporting_deadline_is_after_period_end() {
    let companies = vec![make_company("C001", "Alpha", "US")];
    let mut gen = ComponentAuditGenerator::new(42);
    let group_mat = Decimal::new(1_000_000, 0);
    let pe = period_end();

    let snapshot = gen.generate(&companies, group_mat, "ENG-010", pe);

    for inst in &snapshot.component_instructions {
        assert!(
            inst.reporting_deadline > pe,
            "Reporting deadline {} should be after period end {}",
            inst.reporting_deadline,
            pe
        );
    }
}

// =============================================================================
// Empty input guard
// =============================================================================

#[test]
fn test_empty_companies_returns_empty_snapshot() {
    let mut gen = ComponentAuditGenerator::new(42);
    let snapshot = gen.generate(&[], Decimal::new(1_000_000, 0), "ENG-000", period_end());

    assert!(snapshot.component_auditors.is_empty());
    assert!(snapshot.component_instructions.is_empty());
    assert!(snapshot.component_reports.is_empty());
    assert!(snapshot.group_audit_plan.is_none());
}

// =============================================================================
// Determinism
// =============================================================================

#[test]
fn test_deterministic_with_same_seed() {
    let companies = vec![
        make_company("C001", "Alpha", "US"),
        make_company("C002", "Beta", "DE"),
    ];
    let group_mat = Decimal::new(2_000_000, 0);
    let pe = period_end();

    let snapshot_a = ComponentAuditGenerator::new(42).generate(&companies, group_mat, "ENG", pe);
    let snapshot_b = ComponentAuditGenerator::new(42).generate(&companies, group_mat, "ENG", pe);

    assert_eq!(
        snapshot_a.component_auditors.len(),
        snapshot_b.component_auditors.len()
    );
    assert_eq!(
        snapshot_a.component_instructions.len(),
        snapshot_b.component_instructions.len()
    );
    assert_eq!(
        snapshot_a.component_reports.len(),
        snapshot_b.component_reports.len()
    );

    // Verify first instruction IDs match
    if !snapshot_a.component_instructions.is_empty() {
        assert_eq!(
            snapshot_a.component_instructions[0].id,
            snapshot_b.component_instructions[0].id
        );
    }
}