Skip to main content

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// =============================================================================
162// Enhanced Test Assertions
163// =============================================================================
164
165/// Assert that amounts pass Benford's Law chi-squared test.
166/// Uses the chi-squared statistic with configurable threshold.
167#[macro_export]
168macro_rules! assert_benford_passes {
169    ($amounts:expr, $threshold:expr) => {{
170        let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
171        assert!(
172            passes || chi_squared < $threshold,
173            "Benford's Law test failed: chi-squared={:.4}, threshold={}",
174            chi_squared,
175            $threshold
176        );
177    }};
178    ($amounts:expr) => {{
179        let (chi_squared, passes) = $crate::assertions::check_benford_distribution(&$amounts);
180        assert!(
181            passes,
182            "Benford's Law test failed: chi-squared={:.4}, p < 0.01 threshold=20.090",
183            chi_squared
184        );
185    }};
186}
187
188/// Balance snapshot for coherence testing.
189#[derive(Debug, Clone)]
190pub struct BalanceSnapshot {
191    /// Total assets
192    pub assets: Decimal,
193    /// Total liabilities
194    pub liabilities: Decimal,
195    /// Total equity
196    pub equity: Decimal,
197    /// Period identifier
198    pub period: String,
199}
200
201impl BalanceSnapshot {
202    /// Create a new balance snapshot.
203    pub fn new(assets: Decimal, liabilities: Decimal, equity: Decimal, period: &str) -> Self {
204        Self {
205            assets,
206            liabilities,
207            equity,
208            period: period.into(),
209        }
210    }
211
212    /// Check if the accounting equation holds within tolerance.
213    pub fn is_coherent(&self, tolerance: Decimal) -> bool {
214        let diff = self.assets - (self.liabilities + self.equity);
215        diff.abs() <= tolerance
216    }
217}
218
219/// Assert that balance snapshots maintain accounting equation coherence.
220/// Checks that Assets = Liabilities + Equity within tolerance.
221#[macro_export]
222macro_rules! assert_balance_coherent {
223    ($snapshots:expr, $tolerance:expr) => {{
224        let tolerance =
225            rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
226        for snapshot in $snapshots.iter() {
227            assert!(
228                snapshot.is_coherent(tolerance),
229                "Balance not coherent for period {}: assets={}, liabilities={}, equity={}, diff={}",
230                snapshot.period,
231                snapshot.assets,
232                snapshot.liabilities,
233                snapshot.equity,
234                snapshot.assets - (snapshot.liabilities + snapshot.equity)
235            );
236        }
237    }};
238}
239
240/// Subledger reconciliation data.
241#[derive(Debug, Clone)]
242pub struct SubledgerReconciliation {
243    /// Subledger name (AR, AP, FA, Inventory)
244    pub subledger: String,
245    /// Total from subledger
246    pub subledger_total: Decimal,
247    /// GL control account balance
248    pub gl_balance: Decimal,
249    /// Period
250    pub period: String,
251}
252
253impl SubledgerReconciliation {
254    /// Create new reconciliation data.
255    pub fn new(
256        subledger: &str,
257        subledger_total: Decimal,
258        gl_balance: Decimal,
259        period: &str,
260    ) -> Self {
261        Self {
262            subledger: subledger.into(),
263            subledger_total,
264            gl_balance,
265            period: period.into(),
266        }
267    }
268
269    /// Check if subledger reconciles to GL within tolerance.
270    pub fn is_reconciled(&self, tolerance: Decimal) -> bool {
271        let diff = (self.subledger_total - self.gl_balance).abs();
272        diff <= tolerance
273    }
274
275    /// Get the reconciliation difference.
276    pub fn difference(&self) -> Decimal {
277        self.subledger_total - self.gl_balance
278    }
279}
280
281/// Assert that subledgers reconcile to GL control accounts.
282#[macro_export]
283macro_rules! assert_subledger_reconciled {
284    ($reconciliations:expr, $tolerance:expr) => {{
285        let tolerance =
286            rust_decimal::Decimal::try_from($tolerance).unwrap_or(rust_decimal::Decimal::ZERO);
287        for recon in $reconciliations.iter() {
288            assert!(
289                recon.is_reconciled(tolerance),
290                "Subledger {} not reconciled for period {}: subledger={}, gl={}, diff={}",
291                recon.subledger,
292                recon.period,
293                recon.subledger_total,
294                recon.gl_balance,
295                recon.difference()
296            );
297        }
298    }};
299}
300
301/// Document chain validation result.
302#[derive(Debug, Clone)]
303pub struct DocumentChainResult {
304    /// Chain identifier
305    pub chain_id: String,
306    /// Whether chain is complete
307    pub is_complete: bool,
308    /// Missing steps (if any)
309    pub missing_steps: Vec<String>,
310    /// Total steps expected
311    pub expected_steps: usize,
312    /// Actual steps found
313    pub actual_steps: usize,
314}
315
316impl DocumentChainResult {
317    /// Create a new chain result.
318    pub fn new(chain_id: &str, expected_steps: usize, actual_steps: usize) -> Self {
319        Self {
320            chain_id: chain_id.into(),
321            is_complete: actual_steps >= expected_steps,
322            missing_steps: Vec::new(),
323            expected_steps,
324            actual_steps,
325        }
326    }
327
328    /// Create a complete chain result.
329    pub fn complete(chain_id: &str, steps: usize) -> Self {
330        Self::new(chain_id, steps, steps)
331    }
332
333    /// Create an incomplete chain result.
334    pub fn incomplete(
335        chain_id: &str,
336        expected: usize,
337        actual: usize,
338        missing: Vec<String>,
339    ) -> Self {
340        Self {
341            chain_id: chain_id.into(),
342            is_complete: false,
343            missing_steps: missing,
344            expected_steps: expected,
345            actual_steps: actual,
346        }
347    }
348
349    /// Get completion rate.
350    pub fn completion_rate(&self) -> f64 {
351        if self.expected_steps == 0 {
352            1.0
353        } else {
354            self.actual_steps as f64 / self.expected_steps as f64
355        }
356    }
357}
358
359/// Check document chain completeness rate.
360pub fn check_document_chain_completeness(chains: &[DocumentChainResult]) -> (f64, usize, usize) {
361    if chains.is_empty() {
362        return (1.0, 0, 0);
363    }
364
365    let complete_count = chains.iter().filter(|c| c.is_complete).count();
366    let total_count = chains.len();
367    let rate = complete_count as f64 / total_count as f64;
368
369    (rate, complete_count, total_count)
370}
371
372/// Assert that document chains meet completeness threshold.
373#[macro_export]
374macro_rules! assert_document_chain_complete {
375    ($chains:expr, $threshold:expr) => {{
376        let (rate, complete, total) =
377            $crate::assertions::check_document_chain_completeness(&$chains);
378        assert!(
379            rate >= $threshold,
380            "Document chain completeness {:.2}% below threshold {:.2}%: {}/{} complete",
381            rate * 100.0,
382            $threshold * 100.0,
383            complete,
384            total
385        );
386
387        // Also report incomplete chains for debugging
388        for chain in $chains.iter().filter(|c| !c.is_complete) {
389            eprintln!(
390                "Incomplete chain {}: {}/{} steps, missing: {:?}",
391                chain.chain_id, chain.actual_steps, chain.expected_steps, chain.missing_steps
392            );
393        }
394    }};
395}
396
397/// Fidelity comparison result.
398#[derive(Debug, Clone)]
399pub struct FidelityResult {
400    /// Overall fidelity score (0.0 - 1.0)
401    pub overall_score: f64,
402    /// Statistical fidelity (distribution similarity)
403    pub statistical_score: f64,
404    /// Schema fidelity (structure match)
405    pub schema_score: f64,
406    /// Correlation fidelity (relationship preservation)
407    pub correlation_score: f64,
408    /// Whether fidelity passes threshold
409    pub passes: bool,
410    /// Threshold used
411    pub threshold: f64,
412}
413
414impl FidelityResult {
415    /// Create a new fidelity result.
416    pub fn new(statistical: f64, schema: f64, correlation: f64, threshold: f64) -> Self {
417        // Weighted average: statistical 50%, schema 25%, correlation 25%
418        let overall = statistical * 0.50 + schema * 0.25 + correlation * 0.25;
419
420        Self {
421            overall_score: overall,
422            statistical_score: statistical,
423            schema_score: schema,
424            correlation_score: correlation,
425            passes: overall >= threshold,
426            threshold,
427        }
428    }
429
430    /// Create a perfect fidelity result (for self-comparison).
431    pub fn perfect(threshold: f64) -> Self {
432        Self::new(1.0, 1.0, 1.0, threshold)
433    }
434}
435
436/// Check fidelity between synthetic data and fingerprint.
437pub fn check_fidelity(
438    statistical_score: f64,
439    schema_score: f64,
440    correlation_score: f64,
441    threshold: f64,
442) -> FidelityResult {
443    FidelityResult::new(
444        statistical_score,
445        schema_score,
446        correlation_score,
447        threshold,
448    )
449}
450
451/// Assert that fidelity passes the threshold.
452#[macro_export]
453macro_rules! assert_fidelity_passes {
454    ($result:expr) => {{
455        assert!(
456            $result.passes,
457            "Fidelity check failed: overall={:.4} < threshold={:.4}\n  \
458             statistical={:.4}, schema={:.4}, correlation={:.4}",
459            $result.overall_score,
460            $result.threshold,
461            $result.statistical_score,
462            $result.schema_score,
463            $result.correlation_score
464        );
465    }};
466    ($statistical:expr, $schema:expr, $correlation:expr, $threshold:expr) => {{
467        let result =
468            $crate::assertions::check_fidelity($statistical, $schema, $correlation, $threshold);
469        assert!(
470            result.passes,
471            "Fidelity check failed: overall={:.4} < threshold={:.4}\n  \
472             statistical={:.4}, schema={:.4}, correlation={:.4}",
473            result.overall_score,
474            result.threshold,
475            result.statistical_score,
476            result.schema_score,
477            result.correlation_score
478        );
479    }};
480}
481
482/// Convenience function to compute Mean Absolute Deviation for Benford analysis.
483pub fn benford_mad(amounts: &[Decimal]) -> f64 {
484    let expected = [
485        0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
486    ];
487    let mut counts = [0u64; 9];
488    let mut total = 0u64;
489
490    for amount in amounts.iter() {
491        if *amount > Decimal::ZERO {
492            let first_digit = amount
493                .to_string()
494                .chars()
495                .find(|c| c.is_ascii_digit() && *c != '0')
496                .and_then(|c| c.to_digit(10))
497                .map(|d| d as usize);
498
499            if let Some(d) = first_digit {
500                if (1..=9).contains(&d) {
501                    counts[d - 1] += 1;
502                    total += 1;
503                }
504            }
505        }
506    }
507
508    if total == 0 {
509        return 0.0;
510    }
511
512    // Calculate Mean Absolute Deviation
513    let mut mad = 0.0;
514    for (count, exp) in counts.iter().zip(expected.iter()) {
515        let observed = *count as f64 / total as f64;
516        mad += (observed - exp).abs();
517    }
518
519    mad / 9.0
520}
521
522#[cfg(test)]
523#[allow(clippy::unwrap_used)]
524mod tests {
525    use super::*;
526    use crate::fixtures::*;
527
528    #[test]
529    fn test_is_balanced() {
530        let entry = balanced_journal_entry(Decimal::new(10000, 2));
531        assert!(is_balanced(&entry));
532    }
533
534    #[test]
535    fn test_is_not_balanced() {
536        let entry = unbalanced_journal_entry();
537        assert!(!is_balanced(&entry));
538    }
539
540    #[test]
541    fn test_calculate_imbalance_balanced() {
542        let entry = balanced_journal_entry(Decimal::new(10000, 2));
543        assert_eq!(calculate_imbalance(&entry), Decimal::ZERO);
544    }
545
546    #[test]
547    fn test_calculate_imbalance_unbalanced() {
548        let entry = unbalanced_journal_entry();
549        let imbalance = calculate_imbalance(&entry);
550        assert_ne!(imbalance, Decimal::ZERO);
551    }
552
553    #[test]
554    fn test_check_accounting_equation() {
555        // Assets = 1000, Liabilities = 600, Equity = 400
556        assert!(check_accounting_equation(
557            Decimal::new(1000, 0),
558            Decimal::new(600, 0),
559            Decimal::new(400, 0)
560        ));
561
562        // Unbalanced: Assets = 1000, Liabilities = 600, Equity = 300
563        assert!(!check_accounting_equation(
564            Decimal::new(1000, 0),
565            Decimal::new(600, 0),
566            Decimal::new(300, 0)
567        ));
568    }
569
570    #[test]
571    fn test_check_trial_balance() {
572        let debits = vec![Decimal::new(1000, 0), Decimal::new(500, 0)];
573        let credits = vec![Decimal::new(1500, 0)];
574        assert!(check_trial_balance(&debits, &credits));
575
576        let unbalanced_credits = vec![Decimal::new(1000, 0)];
577        assert!(!check_trial_balance(&debits, &unbalanced_credits));
578    }
579
580    #[test]
581    fn test_benford_distribution_perfect() {
582        // Create a distribution that follows Benford's Law
583        let mut amounts = Vec::new();
584        let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46]; // Per 1000
585
586        for (digit, count) in expected_counts.iter().enumerate() {
587            let base = Decimal::new((digit + 1) as i64, 0);
588            for _ in 0..*count {
589                amounts.push(base);
590            }
591        }
592
593        let (chi_squared, passes) = check_benford_distribution(&amounts);
594        assert!(passes, "Chi-squared: {}", chi_squared);
595    }
596
597    #[test]
598    fn test_assert_balanced_macro() {
599        let entry = balanced_journal_entry(Decimal::new(10000, 2));
600        assert_balanced!(entry); // Should not panic
601    }
602
603    #[test]
604    fn test_assert_all_balanced_macro() {
605        let entries = [
606            balanced_journal_entry(Decimal::new(10000, 2)),
607            balanced_journal_entry(Decimal::new(20000, 2)),
608            balanced_journal_entry(Decimal::new(30000, 2)),
609        ];
610        assert_all_balanced!(entries); // Should not panic
611    }
612
613    // =============================================================================
614    // Tests for new enhanced assertions
615    // =============================================================================
616
617    #[test]
618    fn test_balance_snapshot_coherent() {
619        let snapshot = BalanceSnapshot::new(
620            Decimal::new(1000, 0),
621            Decimal::new(600, 0),
622            Decimal::new(400, 0),
623            "2025-01",
624        );
625        assert!(snapshot.is_coherent(Decimal::ZERO));
626    }
627
628    #[test]
629    fn test_balance_snapshot_incoherent() {
630        let snapshot = BalanceSnapshot::new(
631            Decimal::new(1000, 0),
632            Decimal::new(600, 0),
633            Decimal::new(300, 0), // Assets != L + E
634            "2025-01",
635        );
636        assert!(!snapshot.is_coherent(Decimal::ZERO));
637    }
638
639    #[test]
640    fn test_balance_snapshot_with_tolerance() {
641        let snapshot = BalanceSnapshot::new(
642            Decimal::new(1001, 0), // Off by 1
643            Decimal::new(600, 0),
644            Decimal::new(400, 0),
645            "2025-01",
646        );
647        assert!(!snapshot.is_coherent(Decimal::ZERO));
648        assert!(snapshot.is_coherent(Decimal::new(1, 0)));
649        assert!(snapshot.is_coherent(Decimal::new(5, 0)));
650    }
651
652    #[test]
653    fn test_assert_balance_coherent_macro() {
654        let snapshots = [
655            BalanceSnapshot::new(
656                Decimal::new(1000, 0),
657                Decimal::new(600, 0),
658                Decimal::new(400, 0),
659                "2025-01",
660            ),
661            BalanceSnapshot::new(
662                Decimal::new(1200, 0),
663                Decimal::new(700, 0),
664                Decimal::new(500, 0),
665                "2025-02",
666            ),
667        ];
668        assert_balance_coherent!(snapshots, 0.0);
669    }
670
671    #[test]
672    fn test_subledger_reconciliation() {
673        let recon = SubledgerReconciliation::new(
674            "AR",
675            Decimal::new(50000, 0),
676            Decimal::new(50000, 0),
677            "2025-01",
678        );
679        assert!(recon.is_reconciled(Decimal::ZERO));
680        assert_eq!(recon.difference(), Decimal::ZERO);
681    }
682
683    #[test]
684    fn test_subledger_reconciliation_with_tolerance() {
685        let recon = SubledgerReconciliation::new(
686            "AP",
687            Decimal::new(50010, 0), // Off by 10
688            Decimal::new(50000, 0),
689            "2025-01",
690        );
691        assert!(!recon.is_reconciled(Decimal::new(5, 0)));
692        assert!(recon.is_reconciled(Decimal::new(10, 0)));
693        assert!(recon.is_reconciled(Decimal::new(100, 0)));
694    }
695
696    #[test]
697    fn test_assert_subledger_reconciled_macro() {
698        let reconciliations = [
699            SubledgerReconciliation::new(
700                "AR",
701                Decimal::new(50000, 0),
702                Decimal::new(50000, 0),
703                "2025-01",
704            ),
705            SubledgerReconciliation::new(
706                "AP",
707                Decimal::new(30000, 0),
708                Decimal::new(30000, 0),
709                "2025-01",
710            ),
711        ];
712        assert_subledger_reconciled!(reconciliations, 0.0);
713    }
714
715    #[test]
716    fn test_document_chain_complete() {
717        let chain = DocumentChainResult::complete("PO-001", 5);
718        assert!(chain.is_complete);
719        assert_eq!(chain.completion_rate(), 1.0);
720    }
721
722    #[test]
723    fn test_document_chain_incomplete() {
724        let chain =
725            DocumentChainResult::incomplete("PO-002", 5, 3, vec!["Payment".into(), "Close".into()]);
726        assert!(!chain.is_complete);
727        assert_eq!(chain.completion_rate(), 0.6);
728    }
729
730    #[test]
731    fn test_check_document_chain_completeness() {
732        let chains = vec![
733            DocumentChainResult::complete("PO-001", 5),
734            DocumentChainResult::complete("PO-002", 5),
735            DocumentChainResult::incomplete("PO-003", 5, 3, vec!["Payment".into()]),
736        ];
737
738        let (rate, complete, total) = check_document_chain_completeness(&chains);
739        assert_eq!(complete, 2);
740        assert_eq!(total, 3);
741        assert!((rate - 0.6667).abs() < 0.01);
742    }
743
744    #[test]
745    fn test_assert_document_chain_complete_macro() {
746        let chains = vec![
747            DocumentChainResult::complete("PO-001", 5),
748            DocumentChainResult::complete("PO-002", 5),
749            DocumentChainResult::complete("PO-003", 5),
750        ];
751        assert_document_chain_complete!(chains, 0.9);
752    }
753
754    #[test]
755    fn test_fidelity_result() {
756        let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
757
758        // Weighted: 0.95 * 0.5 + 1.0 * 0.25 + 0.90 * 0.25 = 0.475 + 0.25 + 0.225 = 0.95
759        assert!((result.overall_score - 0.95).abs() < 0.001);
760        assert!(result.passes);
761    }
762
763    #[test]
764    fn test_fidelity_result_fails() {
765        let result = FidelityResult::new(0.50, 0.50, 0.50, 0.80);
766
767        // Weighted: 0.50 * 0.5 + 0.50 * 0.25 + 0.50 * 0.25 = 0.25 + 0.125 + 0.125 = 0.50
768        assert!((result.overall_score - 0.50).abs() < 0.001);
769        assert!(!result.passes);
770    }
771
772    #[test]
773    fn test_fidelity_perfect() {
774        let result = FidelityResult::perfect(0.90);
775        assert_eq!(result.overall_score, 1.0);
776        assert!(result.passes);
777    }
778
779    #[test]
780    fn test_assert_fidelity_passes_macro() {
781        let result = FidelityResult::new(0.95, 1.0, 0.90, 0.80);
782        assert_fidelity_passes!(result);
783    }
784
785    #[test]
786    fn test_assert_fidelity_passes_inline() {
787        assert_fidelity_passes!(0.95, 1.0, 0.90, 0.80);
788    }
789
790    #[test]
791    fn test_benford_mad() {
792        // Create a perfect Benford distribution
793        let mut amounts = Vec::new();
794        let expected_counts = [301, 176, 125, 97, 79, 67, 58, 51, 46];
795
796        for (digit, count) in expected_counts.iter().enumerate() {
797            let base = Decimal::new((digit + 1) as i64, 0);
798            for _ in 0..*count {
799                amounts.push(base);
800            }
801        }
802
803        let mad = benford_mad(&amounts);
804        assert!(
805            mad < 0.01,
806            "Perfect Benford distribution should have very low MAD: {}",
807            mad
808        );
809    }
810
811    #[test]
812    fn test_benford_mad_uniform() {
813        // Create a uniform distribution (bad for Benford)
814        let mut amounts = Vec::new();
815        for digit in 1..=9 {
816            for _ in 0..100 {
817                amounts.push(Decimal::new(digit, 0));
818            }
819        }
820
821        let mad = benford_mad(&amounts);
822        assert!(
823            mad > 0.02,
824            "Uniform distribution should have high MAD: {}",
825            mad
826        );
827    }
828}