tktax_burst/
lib.rs

1// ---------------- [ File: tktax-burst/src/lib.rs ]
2#[macro_use] mod imports; use imports::*;
3
4x!{build_date_windows}
5x!{bursts}
6x!{collect_daily_totals}
7x!{merge_overlapping_burst_windows}
8x!{print_merged_bursts}
9x!{print_transaction_lines}
10
11#[cfg(test)]
12#[disable]
13mod test_heavy_bursts {
14    use super::*;
15    use chrono::NaiveDate;
16
17    fn ma(amount: f64) -> MonetaryAmount {
18        MonetaryAmount::from(amount)
19    }
20
21    #[test]
22    fn test_collect_daily_totals() {
23        // Build some mock transactions
24        let txns = vec![
25            mock_transaction("2025-01-01", 10.0),
26            mock_transaction("2025-01-01", 5.0),
27            mock_transaction("2025-01-02", 20.0),
28            mock_transaction("2025-01-03", -5.0),
29        ];
30        let daily = collect_daily_totals(&txns);
31        assert_eq!(daily[&nd("2025-01-01")], ma(15.0));
32        assert_eq!(daily[&nd("2025-01-02")], ma(20.0));
33        assert_eq!(daily[&nd("2025-01-03")], ma(-5.0));
34    }
35
36    // -------------- Mock Helpers -----------------
37
38    fn nd(s: &str) -> NaiveDate {
39        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
40    }
41
42    fn mock_transaction(date_str: &str, amt: f64) -> Transaction {
43        // Here you might build a minimal Transaction with the provided date and amount
44        Transaction::new(nd(date_str), ma(amt), "mock desc")
45    }
46
47    //-------------------------------------------------------------------------
48    // 2) Fake or Mock Transaction + Account for Testing
49    //
50    //    In a real codebase, you'd use your actual Transaction/Account
51    //    structs. The only requirement here is that we can produce a slice
52    //    of Transaction objects, each with a transaction_date() and amount().
53    //-------------------------------------------------------------------------
54
55    #[derive(Clone)]
56    struct MockTransaction {
57        date: NaiveDate,
58        amt:  MonetaryAmount,
59    }
60
61    impl MockTransaction {
62        pub fn new(date: NaiveDate, amt: MonetaryAmount) -> Self {
63            Self { date, amt }
64        }
65    }
66
67    // Minimal trait or direct methods to satisfy what's needed by collect_daily_totals:
68    impl MockTransaction {
69        pub fn transaction_date(&self) -> &NaiveDate {
70            &self.date
71        }
72        pub fn amount(&self) -> MonetaryAmount {
73            self.amt
74        }
75    }
76
77    // Minimal "Account" that implements `txns() -> &[Transaction]` for testing
78    struct MockAccount {
79        txns: Vec<MockTransaction>,
80    }
81
82    impl MockAccount {
83        pub fn new(txns: Vec<MockTransaction>) -> Self {
84            Self { txns }
85        }
86        pub fn txns(&self) -> &[MockTransaction] {
87            &self.txns
88        }
89
90        // Let’s mock total_transaction_amount_within_dates
91        // by just summing any transaction in [start_date, end_date].
92        pub fn total_transaction_amount_within_dates(
93            &self, 
94            start_date: NaiveDate, 
95            end_date: NaiveDate
96        ) -> MonetaryAmount 
97        {
98            let mut total = MonetaryAmount::zero();
99            for tx in &self.txns {
100                let d = tx.transaction_date();
101                if *d >= start_date && *d <= end_date {
102                    total += tx.amount();
103                }
104            }
105            total
106        }
107
108        // Similarly for transactions_within_dates
109        pub fn transactions_within_dates(
110            &self, 
111            start_date: NaiveDate, 
112            end_date: NaiveDate
113        ) -> Vec<MockTransaction> 
114        {
115            self.txns
116                .iter()
117                .filter(|tx| {
118                    let d = tx.transaction_date();
119                    *d >= start_date && *d <= end_date
120                })
121                .cloned()
122                .collect()
123        }
124    }
125
126    // Implement the same trait we used for an actual Account,
127    // by hooking to the logic for "fake" transactions.
128    impl FindHeavyTransactionBursts for MockAccount {
129        fn print_heavy_bursts(&self, top_n: usize, window_size: usize) {
130            // we can just copy in or call the same method from the real code 
131            let heaviest_bursts = self.find_heaviest_bursts(window_size);
132            let top_n_bursts = heaviest_bursts.into_iter().take(top_n).collect::<Vec<_>>();
133            let merged_bursts = merge_overlapping_burst_windows(top_n_bursts);
134
135            println!(
136                "Top {} heaviest bursts of activity (calculated with possible overlap using a {}-day window):", 
137                merged_bursts.len(), 
138                window_size
139            );
140
141            for (i, &(start_date, end_date)) in merged_bursts.iter().enumerate() {
142                let total = self.total_transaction_amount_within_dates(start_date, end_date);
143                println!();
144                println!("Burst {}) {} to {}: {}", i + 1, start_date, end_date, total);
145
146                let relevant = self.transactions_within_dates(start_date, end_date);
147                for tx in relevant {
148                    println!("  {}  {}", tx.transaction_date(), tx.amount());
149                }
150            }
151            println!();
152        }
153
154        fn find_heaviest_bursts(&self, window_size: usize) 
155            -> Vec<(NaiveDate, NaiveDate, MonetaryAmount)>
156        {
157            // identical logic as in your main code, but referencing our mock:
158            let daily_totals = collect_daily_totals(self.txns());
159            let mut windows = build_date_windows(&daily_totals, window_size);
160            sort_windows_desc(&mut windows);
161            windows
162        }
163    }
164
165    //-------------------------------------------------------------------------
166    // 3) Test Each Subroutine
167    //-------------------------------------------------------------------------
168    #[test]
169    fn test_collect_daily_totals_basic() {
170        let txns = vec![
171            MockTransaction::new(nd("2025-01-01"), ma(10.0)),
172            MockTransaction::new(nd("2025-01-01"), ma(5.0)),
173            MockTransaction::new(nd("2025-01-02"), ma(20.0)),
174            MockTransaction::new(nd("2025-01-03"), ma(-5.0)),
175        ];
176        let daily = collect_daily_totals(&txns);
177        assert_eq!(daily[&nd("2025-01-01")], ma(15.0));
178        assert_eq!(daily[&nd("2025-01-02")], ma(20.0));
179        assert_eq!(daily[&nd("2025-01-03")], ma(-5.0));
180    }
181
182    #[test]
183    fn test_collect_daily_totals_empty() {
184        let txns: Vec<MockTransaction> = vec![];
185        let daily = collect_daily_totals(&txns);
186        assert!(daily.is_empty());
187    }
188
189    #[test]
190    fn test_build_date_windows() {
191        // day1=10, day2=20 => window_size=2 => 
192        // for day1 => total = day1+day2=30
193        // for day2 => total = day2=20 (no day3 entry)
194        let mut daily = HashMap::new();
195        daily.insert(nd("2025-01-01"), ma(10.0));
196        daily.insert(nd("2025-01-02"), ma(20.0));
197        let windows = build_date_windows(&daily, 2);
198
199        assert_eq!(windows.len(), 2);
200        // We can check explicitly:
201        assert!(windows.contains(&(nd("2025-01-01"), nd("2025-01-02"), ma(30.0))));
202        assert!(windows.contains(&(nd("2025-01-02"), nd("2025-01-03"), ma(20.0))));
203    }
204
205    #[test]
206    fn test_build_date_windows_single_day_window() {
207        // If window_size=1, each “window” is just that day’s total.
208        let mut daily = HashMap::new();
209        daily.insert(nd("2025-01-01"), ma(10.0));
210        daily.insert(nd("2025-01-02"), ma(20.0));
211        let windows = build_date_windows(&daily, 1);
212        assert_eq!(windows.len(), 2);
213
214        // day1 => start=1/1, end=1/1 => total=10
215        // day2 => start=1/2, end=1/2 => total=20
216        assert!(windows.contains(&(nd("2025-01-01"), nd("2025-01-01"), ma(10.0))));
217        assert!(windows.contains(&(nd("2025-01-02"), nd("2025-01-02"), ma(20.0))));
218    }
219
220    #[test]
221    fn test_sort_windows_desc() {
222        let mut windows = vec![
223            (nd("2025-01-01"), nd("2025-01-01"), ma(10.0)),
224            (nd("2025-01-02"), nd("2025-01-02"), ma(50.0)),
225            (nd("2025-01-03"), nd("2025-01-03"), ma(20.0)),
226        ];
227        sort_windows_desc(&mut windows);
228        // Should become [ (1/2 => 50.0), (1/3 => 20.0), (1/1 => 10.0) ]
229        assert_eq!(windows[0].2, ma(50.0));
230        assert_eq!(windows[1].2, ma(20.0));
231        assert_eq!(windows[2].2, ma(10.0));
232    }
233
234    #[test]
235    fn test_merge_overlapping_burst_windows() {
236        let input = vec![
237            (nd("2025-01-01"), nd("2025-01-03"), ma(30.0)),
238            (nd("2025-01-02"), nd("2025-01-05"), ma(40.0)),
239            (nd("2025-02-01"), nd("2025-02-01"), ma(10.0)),
240        ];
241        let merged = merge_overlapping_burst_windows(input);
242        // The first two overlap => merged => [1/1..1/5], third is separate
243        assert_eq!(merged.len(), 2);
244        assert_eq!(merged[0], (nd("2025-01-01"), nd("2025-01-05")));
245        assert_eq!(merged[1], (nd("2025-02-01"), nd("2025-02-01")));
246    }
247
248    //-------------------------------------------------------------------------
249    // 4) Test the Trait Methods on a Realistic “Account”-like structure
250    //-------------------------------------------------------------------------
251
252    #[test]
253    fn test_find_heaviest_bursts_empty_account() {
254        let acc = MockAccount::new(vec![]);
255        // Should produce no windows
256        let windows = acc.find_heaviest_bursts(7);
257        assert!(windows.is_empty());
258    }
259
260    #[test]
261    fn test_find_heaviest_bursts_basic() {
262        // Day1=10, Day2=20, Day3=30 => with 2-day windows:
263        // day1 => [d1..d2]=30, day2=> [d2..d3]=50, day3=> [d3..d4]=30
264        let txns = vec![
265            MockTransaction::new(nd("2025-01-01"), ma(10.0)),
266            MockTransaction::new(nd("2025-01-02"), ma(20.0)),
267            MockTransaction::new(nd("2025-01-03"), ma(30.0)),
268        ];
269        let acc = MockAccount::new(txns);
270        let windows = acc.find_heaviest_bursts(2);
271        // Should be sorted desc => top window = day2..day3 => 50
272        assert_eq!(windows[0], (nd("2025-01-02"), nd("2025-01-03"), ma(50.0)));
273    }
274
275    #[test]
276    fn test_print_heavy_bursts_basic() {
277        // We'll capture stdout to test what gets printed
278        // This approach uses "cargo test -- --nocapture" to see the actual output
279        // or we can parse the captured string for specific lines.
280        let txns = vec![
281            MockTransaction::new(nd("2025-01-01"), ma(100.0)),
282            MockTransaction::new(nd("2025-01-02"), ma(50.0)),
283            MockTransaction::new(nd("2025-01-10"), ma(200.0)),
284        ];
285        let acc = MockAccount::new(txns);
286
287        // We'll do a 3-day window, top 2 bursts
288        let output = capture_stdout(|| {
289            acc.print_heavy_bursts(2, 3);
290        });
291
292        // In that 3-day window, "2025-01-10..2025-01-12" => 200.0
293        // "2025-01-01..2025-01-03" => 150.0
294        // So these should appear in the printed text.
295        assert!(output.contains("Top 2 heaviest bursts of activity"));
296        assert!(output.contains("Burst 1) 2025-01-10 to 2025-01-12:  $200.00"));
297        assert!(output.contains("Burst 2) 2025-01-01 to 2025-01-03:  $150.00"));
298    }
299
300    //-------------------------------------------------------------------------
301    // 6) Optional: Utility for capturing stdout in tests
302    //    Adapted from common patterns or from "assert_cmd" crate usage
303    //-------------------------------------------------------------------------
304    fn capture_stdout<F: FnOnce()>(f: F) -> String {
305        use std::io::{self, Read, Write};
306        let backup = io::stdout();
307        let mut writer = io::Cursor::new(Vec::new());
308        io::set_print(Some(Box::new(&mut writer)));
309        f();
310        io::set_print(None);
311
312        let bytes = writer.into_inner();
313        String::from_utf8(bytes).unwrap_or_default()
314    }
315}