datasynth_test_utils/
assertions.rs

1//! Custom assertion macros for testing accounting invariants.
2
3use datasynth_core::models::JournalEntry;
4use rust_decimal::Decimal;
5
6/// Assert that a journal entry is balanced (debits equal credits).
7#[macro_export]
8macro_rules! assert_balanced {
9    ($entry:expr) => {{
10        let entry = &$entry;
11        let total_debits: rust_decimal::Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
12        let total_credits: rust_decimal::Decimal =
13            entry.lines.iter().map(|l| l.credit_amount).sum();
14        assert_eq!(
15            total_debits, total_credits,
16            "Journal entry is not balanced: debits={}, credits={}",
17            total_debits, total_credits
18        );
19    }};
20}
21
22/// Assert that all journal entries in a collection are balanced.
23#[macro_export]
24macro_rules! assert_all_balanced {
25    ($entries:expr) => {{
26        for (i, entry) in $entries.iter().enumerate() {
27            let total_debits: rust_decimal::Decimal =
28                entry.lines.iter().map(|l| l.debit_amount).sum();
29            let total_credits: rust_decimal::Decimal =
30                entry.lines.iter().map(|l| l.credit_amount).sum();
31            assert_eq!(
32                total_debits, total_credits,
33                "Journal entry {} is not balanced: debits={}, credits={}",
34                i, total_debits, total_credits
35            );
36        }
37    }};
38}
39
40/// Assert that an amount follows Benford's Law distribution within tolerance.
41/// This checks if the first digit distribution matches expected frequencies.
42#[macro_export]
43macro_rules! assert_benford_compliant {
44    ($amounts:expr, $tolerance:expr) => {{
45        let amounts = &$amounts;
46        let expected = [0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046];
47        let mut counts = [0u64; 9];
48        let mut total = 0u64;
49
50        for amount in amounts.iter() {
51            if *amount > rust_decimal::Decimal::ZERO {
52                let first_digit = amount
53                    .to_string()
54                    .chars()
55                    .find(|c| c.is_ascii_digit() && *c != '0')
56                    .map(|c| c.to_digit(10).unwrap() as usize);
57
58                if let Some(d) = first_digit {
59                    if d >= 1 && d <= 9 {
60                        counts[d - 1] += 1;
61                        total += 1;
62                    }
63                }
64            }
65        }
66
67        if total > 0 {
68            for (i, (count, exp)) in counts.iter().zip(expected.iter()).enumerate() {
69                let observed = *count as f64 / total as f64;
70                let diff = (observed - exp).abs();
71                assert!(
72                    diff < $tolerance,
73                    "Benford's Law violation for digit {}: observed={:.4}, expected={:.4}, diff={:.4}",
74                    i + 1,
75                    observed,
76                    exp,
77                    diff
78                );
79            }
80        }
81    }};
82}
83
84/// Check if a journal entry is balanced.
85pub fn is_balanced(entry: &JournalEntry) -> bool {
86    let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
87    let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
88    total_debits == total_credits
89}
90
91/// Calculate the imbalance of a journal entry.
92pub fn calculate_imbalance(entry: &JournalEntry) -> Decimal {
93    let total_debits: Decimal = entry.lines.iter().map(|l| l.debit_amount).sum();
94    let total_credits: Decimal = entry.lines.iter().map(|l| l.credit_amount).sum();
95    total_debits - total_credits
96}
97
98/// Check if amounts follow Benford's Law distribution.
99/// Returns the chi-squared statistic and whether it passes the test at p < 0.05.
100pub fn check_benford_distribution(amounts: &[Decimal]) -> (f64, bool) {
101    let expected = [
102        0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
103    ];
104    let mut counts = [0u64; 9];
105    let mut total = 0u64;
106
107    for amount in amounts.iter() {
108        if *amount > Decimal::ZERO {
109            let first_digit = amount
110                .to_string()
111                .chars()
112                .find(|c| c.is_ascii_digit() && *c != '0')
113                .map(|c| c.to_digit(10).unwrap() as usize);
114
115            if let Some(d) = first_digit {
116                if (1..=9).contains(&d) {
117                    counts[d - 1] += 1;
118                    total += 1;
119                }
120            }
121        }
122    }
123
124    if total == 0 {
125        return (0.0, true);
126    }
127
128    // Calculate chi-squared statistic
129    let mut chi_squared = 0.0;
130    for (count, exp) in counts.iter().zip(expected.iter()) {
131        let expected_count = exp * total as f64;
132        if expected_count > 0.0 {
133            let diff = *count as f64 - expected_count;
134            chi_squared += diff * diff / expected_count;
135        }
136    }
137
138    // Critical value for chi-squared with 8 degrees of freedom at p < 0.05 is 15.507
139    // At p < 0.01 is 20.090
140    let passes = chi_squared < 20.090;
141
142    (chi_squared, passes)
143}
144
145/// Check that the accounting equation holds: Assets = Liabilities + Equity
146pub fn check_accounting_equation(
147    total_assets: Decimal,
148    total_liabilities: Decimal,
149    total_equity: Decimal,
150) -> bool {
151    total_assets == total_liabilities + total_equity
152}
153
154/// Verify trial balance is balanced (total debits = total credits).
155pub fn check_trial_balance(debit_balances: &[Decimal], credit_balances: &[Decimal]) -> bool {
156    let total_debits: Decimal = debit_balances.iter().copied().sum();
157    let total_credits: Decimal = credit_balances.iter().copied().sum();
158    total_debits == total_credits
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::fixtures::*;
165
166    #[test]
167    fn test_is_balanced() {
168        let entry = balanced_journal_entry(Decimal::new(10000, 2));
169        assert!(is_balanced(&entry));
170    }
171
172    #[test]
173    fn test_is_not_balanced() {
174        let entry = unbalanced_journal_entry();
175        assert!(!is_balanced(&entry));
176    }
177
178    #[test]
179    fn test_calculate_imbalance_balanced() {
180        let entry = balanced_journal_entry(Decimal::new(10000, 2));
181        assert_eq!(calculate_imbalance(&entry), Decimal::ZERO);
182    }
183
184    #[test]
185    fn test_calculate_imbalance_unbalanced() {
186        let entry = unbalanced_journal_entry();
187        let imbalance = calculate_imbalance(&entry);
188        assert_ne!(imbalance, Decimal::ZERO);
189    }
190
191    #[test]
192    fn test_check_accounting_equation() {
193        // Assets = 1000, Liabilities = 600, Equity = 400
194        assert!(check_accounting_equation(
195            Decimal::new(1000, 0),
196            Decimal::new(600, 0),
197            Decimal::new(400, 0)
198        ));
199
200        // Unbalanced: Assets = 1000, Liabilities = 600, Equity = 300
201        assert!(!check_accounting_equation(
202            Decimal::new(1000, 0),
203            Decimal::new(600, 0),
204            Decimal::new(300, 0)
205        ));
206    }
207
208    #[test]
209    fn test_check_trial_balance() {
210        let debits = vec![Decimal::new(1000, 0), Decimal::new(500, 0)];
211        let credits = vec![Decimal::new(1500, 0)];
212        assert!(check_trial_balance(&debits, &credits));
213
214        let unbalanced_credits = vec![Decimal::new(1000, 0)];
215        assert!(!check_trial_balance(&debits, &unbalanced_credits));
216    }
217
218    #[test]
219    fn test_benford_distribution_perfect() {
220        // Create a distribution that follows Benford's Law
221        let mut amounts = Vec::new();
222        let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46]; // Per 1000
223
224        for (digit, count) in expected_counts.iter().enumerate() {
225            let base = Decimal::new((digit + 1) as i64, 0);
226            for _ in 0..*count {
227                amounts.push(base);
228            }
229        }
230
231        let (chi_squared, passes) = check_benford_distribution(&amounts);
232        assert!(passes, "Chi-squared: {}", chi_squared);
233    }
234
235    #[test]
236    fn test_assert_balanced_macro() {
237        let entry = balanced_journal_entry(Decimal::new(10000, 2));
238        assert_balanced!(entry); // Should not panic
239    }
240
241    #[test]
242    fn test_assert_all_balanced_macro() {
243        let entries = [
244            balanced_journal_entry(Decimal::new(10000, 2)),
245            balanced_journal_entry(Decimal::new(20000, 2)),
246            balanced_journal_entry(Decimal::new(30000, 2)),
247        ];
248        assert_all_balanced!(entries); // Should not panic
249    }
250}