diff --git a/app/src/conn.rs b/app/src/conn.rs index dc96cd1c..95053d21 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -140,6 +140,10 @@ impl DbConn { Ok(()) })?; + if let Some(details) = tx.details { + self.cache.new_details(details.to_string()); + } + Ok(()) } @@ -172,10 +176,14 @@ impl DbConn { Ok(()) })?; + if let Some(details) = new_tx.details { + self.cache.new_details(details.to_string()); + } + Ok(()) } - pub fn add_new_methods(&mut self, method_list: &Vec) -> Result<()> { + pub fn add_new_methods(&mut self, method_list: &[String]) -> Result<()> { self.conn.transaction::<_, Error, _>(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); diff --git a/app/src/migration.rs b/app/src/migration.rs index b8488fe8..d3e14881 100644 --- a/app/src/migration.rs +++ b/app/src/migration.rs @@ -94,7 +94,7 @@ pub fn start_migration(mut old_db_conn: DbConn, db_conn: &mut DbConn) -> Result< columns.remove(0); } - let tx_methods = columns.into_iter().map(|c| c.name).collect(); + let tx_methods = columns.into_iter().map(|c| c.name).collect::>(); let new_methods = add_new_tx_methods(&tx_methods, db_conn)?; db_conn.cache.new_tx_methods(new_methods); diff --git a/app/src/modifier/new_method.rs b/app/src/modifier/new_method.rs index 63e5c079..65f68015 100644 --- a/app/src/modifier/new_method.rs +++ b/app/src/modifier/new_method.rs @@ -3,19 +3,16 @@ use rex_db::ConnCache; use rex_db::models::{Balance, NewTxMethod, TxMethod}; pub(crate) fn add_new_tx_methods( - method_list: &Vec, + method_list: &[String], db_conn: &mut impl ConnCache, ) -> Result> { - let mut last_position = TxMethod::get_last_position(db_conn)?; - let mut methods = Vec::new(); - for method in method_list { + for (last_position, method) in (TxMethod::get_last_position(db_conn)?..).zip(method_list.iter()) + { let new_method = NewTxMethod::new(method, last_position + 1); methods.push(new_method); - - last_position += 1; } let mut new_methods = Vec::new(); diff --git a/app/src/utils.rs b/app/src/utils.rs index 04b6f2ab..bc20f105 100644 --- a/app/src/utils.rs +++ b/app/src/utils.rs @@ -75,3 +75,144 @@ pub fn compare_change_opt(current: Dollar, previous: Option) -> String { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use rex_db::models::AmountNature; + use rex_shared::models::{Cent, Dollar}; + + #[test] + fn test_month_name_to_num_all_months() { + assert_eq!(month_name_to_num("January"), 1); + assert_eq!(month_name_to_num("February"), 2); + assert_eq!(month_name_to_num("March"), 3); + assert_eq!(month_name_to_num("April"), 4); + assert_eq!(month_name_to_num("May"), 5); + assert_eq!(month_name_to_num("June"), 6); + assert_eq!(month_name_to_num("July"), 7); + assert_eq!(month_name_to_num("August"), 8); + assert_eq!(month_name_to_num("September"), 9); + assert_eq!(month_name_to_num("October"), 10); + assert_eq!(month_name_to_num("November"), 11); + assert_eq!(month_name_to_num("December"), 12); + } + + #[test] + #[should_panic(expected = "Invalid month name")] + fn test_month_name_to_num_invalid_panics() { + month_name_to_num("NotAMonth"); + } + + #[test] + fn test_month_year_to_unique() { + assert_eq!(month_year_to_unique(6, 2024), 202406); + assert_eq!(month_year_to_unique(12, 2024), 202412); + assert_eq!(month_year_to_unique(1, 2000), 200001); + } + + #[test] + fn test_get_percentages_normal() { + let (p1, p2) = get_percentages(30.0, 70.0); + assert!((p1 - 30.0).abs() < 0.001); + assert!((p2 - 70.0).abs() < 0.001); + } + + #[test] + fn test_get_percentages_equal() { + let (p1, p2) = get_percentages(50.0, 50.0); + assert!((p1 - 50.0).abs() < 0.001); + assert!((p2 - 50.0).abs() < 0.001); + } + + #[test] + fn test_get_percentages_both_zero() { + let (p1, p2) = get_percentages(0.0, 0.0); + assert_eq!(p1, 0.0); + assert_eq!(p2, 0.0); + } + + #[test] + fn test_get_percentages_one_zero() { + let (p1, p2) = get_percentages(0.0, 100.0); + assert_eq!(p1, 0.0); + assert!((p2 - 100.0).abs() < 0.001); + } + + #[test] + fn test_parse_amount_nature_exact() { + let result = parse_amount_nature_cent("50.00").unwrap().unwrap(); + assert!(matches!(result, AmountNature::Exact(c) if c == Cent::new(5000))); + } + + #[test] + fn test_parse_amount_nature_more_than() { + let result = parse_amount_nature_cent(">100").unwrap().unwrap(); + assert!(matches!(result, AmountNature::MoreThan(c) if c == Cent::new(10000))); + } + + #[test] + fn test_parse_amount_nature_less_than() { + let result = parse_amount_nature_cent("<50").unwrap().unwrap(); + assert!(matches!(result, AmountNature::LessThan(c) if c == Cent::new(5000))); + } + + #[test] + fn test_parse_amount_nature_more_than_equal() { + let result = parse_amount_nature_cent(">=25.50").unwrap().unwrap(); + assert!(matches!(result, AmountNature::MoreThanEqual(c) if c == Cent::new(2550))); + } + + #[test] + fn test_parse_amount_nature_less_than_equal() { + let result = parse_amount_nature_cent("<=10.00").unwrap().unwrap(); + assert!(matches!(result, AmountNature::LessThanEqual(c) if c == Cent::new(1000))); + } + + #[test] + fn test_parse_amount_nature_empty() { + let result = parse_amount_nature_cent(" ").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_parse_amount_nature_invalid() { + assert!(parse_amount_nature_cent("abc").is_err()); + } + + #[test] + fn test_compare_change_increase() { + let result = compare_change(Dollar::new(150.0), Dollar::new(100.0)); + assert_eq!(result, "↑50.00"); + } + + #[test] + fn test_compare_change_decrease() { + let result = compare_change(Dollar::new(50.0), Dollar::new(100.0)); + assert_eq!(result, "↓50.00"); + } + + #[test] + fn test_compare_change_zero_previous() { + let result = compare_change(Dollar::new(100.0), Dollar::new(0.0)); + assert_eq!(result, "∞"); + } + + #[test] + fn test_compare_change_opt_some() { + let result = compare_change_opt(Dollar::new(200.0), Some(Dollar::new(100.0))); + assert_eq!(result, "↑100.00"); + } + + #[test] + fn test_compare_change_opt_none() { + let result = compare_change_opt(Dollar::new(100.0), None); + assert_eq!(result, "∞"); + } + + #[test] + fn test_compare_change_opt_previous_zero() { + let result = compare_change_opt(Dollar::new(100.0), Some(Dollar::new(0.0))); + assert_eq!(result, "∞"); + } +} diff --git a/app/src/views/tx_view.rs b/app/src/views/tx_view.rs index 42a121b3..3d0154c4 100644 --- a/app/src/views/tx_view.rs +++ b/app/src/views/tx_view.rs @@ -489,37 +489,43 @@ impl TxViewGroup { index_2: usize, db_conn: &mut impl ConnCache, ) -> Result { - let tx_1 = self.0.get(index_1).unwrap(); - let tx_2 = self.0.get(index_2).unwrap(); + let target_date = { + let tx_1 = self.0.get(index_1).unwrap(); + let tx_2 = self.0.get(index_2).unwrap(); - // Can't switch index if not in the same date - if tx_1.tx.date.date() != tx_2.tx.date.date() { - return Ok(false); - } - - let tx_1_order = tx_1.tx.display_order; - let tx_2_order = tx_2.tx.display_order; + if tx_1.tx.date.date() != tx_2.tx.date.date() { + return Ok(false); + } - let new_tx_1_order = if tx_2_order == 0 { - tx_2.tx.id - } else { - tx_2_order - }; - let new_tx_2_order = if tx_1_order == 0 { - tx_1.tx.id - } else { - tx_1_order + tx_1.tx.date.date() }; - let tx_1 = self.0.get_mut(index_1).unwrap(); - tx_1.tx.display_order = new_tx_1_order; + // If has unset/display order = 0, set display order from 1 to N. + // Once order has been set, only then switch display order and commit + let has_unset = self + .0 + .iter() + .any(|tv| tv.tx.date.date() == target_date && tv.tx.display_order == 0); + + if has_unset { + let mut order = 1i32; + for tx_view in &mut self.0 { + if tx_view.tx.date.date() == target_date { + tx_view.tx.display_order = order; + tx_view.tx.set_display_order(db_conn)?; + order += 1; + } + } + } - tx_1.tx.set_display_order(db_conn)?; + let tx_1_order = self.0[index_1].tx.display_order; + let tx_2_order = self.0[index_2].tx.display_order; - let tx_2 = self.0.get_mut(index_2).unwrap(); - tx_2.tx.display_order = new_tx_2_order; + self.0[index_1].tx.display_order = tx_2_order; + self.0[index_1].tx.set_display_order(db_conn)?; - tx_2.tx.set_display_order(db_conn)?; + self.0[index_2].tx.display_order = tx_1_order; + self.0[index_2].tx.set_display_order(db_conn)?; Ok(true) } diff --git a/app/tests/activity_view.rs b/app/tests/activity_view.rs new file mode 100644 index 00000000..821b23a9 --- /dev/null +++ b/app/tests/activity_view.rs @@ -0,0 +1,260 @@ +use chrono::{Datelike, Local}; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn activity_view_empty() { + let file_name = "test_activity_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Query a month with no activity + let view = db_conn + .get_activity_view_with_str("January", "2020") + .unwrap(); + + assert!(view.is_empty()); + assert_eq!(view.total_activity(), 0); + assert!(view.get_activity_table().is_empty()); + assert_eq!(view.get_activity_txs_table(None).len(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn activity_view_after_add_tx() { + let file_name = "test_activity_add.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-05-01", + "Test tx", + "Cash", + "", + "10.00", + "Expense", + "Tag", + ); + + // Activity date is Local::now(), so query the current month + let now = Local::now().naive_local(); + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let month = month_names[now.month0() as usize]; + let year = now.year().to_string(); + + let view = db_conn.get_activity_view_with_str(month, &year).unwrap(); + + assert!(!view.is_empty()); + assert!(view.total_activity() >= 1); + + // get_activity_txs returns the transaction snapshots within the activity + let txs = view.get_activity_txs(0); + assert!(!txs.is_empty()); + + // add_extra_field is false for AddTx + assert!(!view.add_extra_field(0)); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn activity_view_edit_has_extra_field() { + let file_name = "test_activity_edit.sqlite"; + let mut db_conn = create_test_db(file_name); + + let old_tx = add_tx( + &mut db_conn, + "2024-06-01", + "Original", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + + let new_tx = rex_app::modifier::parse_tx_fields( + "2024-06-01", + "Changed", + "Cash", + "", + "20.00", + "Expense", + &db_conn, + ) + .unwrap(); + db_conn.edit_tx(&old_tx, new_tx, "A").unwrap(); + + let now = Local::now().naive_local(); + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let month = month_names[now.month0() as usize]; + let year = now.year().to_string(); + + let view = db_conn.get_activity_view_with_str(month, &year).unwrap(); + + // Find the EditTx activity + let mut found = false; + for i in 0..view.total_activity() { + if view.add_extra_field(i) { + found = true; + // EditTx should have old and new tx displayed + let table = view.get_activity_txs_table(Some(i)); + assert!(!table.is_empty()); + break; + } + } + assert!(found, "Expected an EditTx activity with extra field"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn activity_view_get_table_returns_formatted() { + let file_name = "test_activity_table.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "Test", + "Cash", + "", + "15.00", + "Expense", + "Tag", + ); + + let now = Local::now().naive_local(); + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let month = month_names[now.month0() as usize]; + let year = now.year().to_string(); + + let view = db_conn.get_activity_view_with_str(month, &year).unwrap(); + + let table = view.get_activity_table(); + assert!(!table.is_empty()); + // Each activity row has date and activity type + assert!( + table[0].len() >= 2, + "Activity table row should have date + type" + ); + + let txs_table = view.get_activity_txs_table(Some(0)); + assert!(!txs_table.is_empty()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn activity_view_position_swap_has_extra_field() { + let file_name = "test_activity_swap.sqlite"; + let mut db_conn = create_test_db(file_name); + + use chrono::NaiveDate; + use rex_app::conn::FetchNature; + + add_tx( + &mut db_conn, + "2024-08-01", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-08-01", + "Second", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + + let now = Local::now().naive_local(); + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let month = month_names[now.month0() as usize]; + let year = now.year().to_string(); + + let view = db_conn.get_activity_view_with_str(month, &year).unwrap(); + + let mut found = false; + for i in 0..view.total_activity() { + if view.add_extra_field(i) { + found = true; + break; + } + } + assert!(found, "Expected a PositionSwap or EditTx activity"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/add_tx.rs b/app/tests/add_tx.rs index b73106b8..5dcfe219 100644 --- a/app/tests/add_tx.rs +++ b/app/tests/add_tx.rs @@ -1,5 +1,5 @@ use chrono::NaiveDate; -use rex_app::conn::FetchNature; +use rex_app::conn::{DbConn, FetchNature}; use rex_app::modifier::parse_tx_fields; use rex_db::ConnCache; use std::fs; @@ -14,6 +14,7 @@ fn add_tx_test() { let mut db_conn = create_test_db(file_name); let cash_method = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_method = db_conn.cache().get_method_id("Bank").unwrap(); let tx_list = [ [ @@ -147,6 +148,294 @@ fn add_tx_test() { ); } + // Verify individual tx fields after all inserts — October has 4 txs + let oct = NaiveDate::from_ymd_opt(2022, 10, 1).unwrap(); + let oct_txs = db_conn + .fetch_txs_with_date(oct, FetchNature::Monthly) + .unwrap(); + + let tx0 = oct_txs.get_tx(0); + assert_eq!(tx0.details, Some("Inheritance".into())); + assert_eq!(tx0.amount.value(), 500000); + assert_eq!(tx0.from_method.name, "Cash"); + assert_eq!(tx0.tags[0].name, "Inheritance"); + assert_eq!(tx0.to_array(false).len(), 6); + + let tx1 = oct_txs.get_tx(1); + assert_eq!(tx1.details, Some("Groceries".into())); + assert_eq!(tx1.tags[0].name, "Groceries"); + + let tx2 = oct_txs.get_tx(2); + assert_eq!(tx2.details, Some("More Groceries".into())); + // Same tag "Groceries" reused + assert_eq!(tx2.tags[0].name, "Groceries"); + + let tx3 = oct_txs.get_tx(3); + assert_eq!(tx3.details, Some("Rent".into())); + assert_eq!(tx3.tags[0].name, "Rent"); + + // July's single tx + let jul = NaiveDate::from_ymd_opt(2022, 7, 1).unwrap(); + let jul_txs = db_conn + .fetch_txs_with_date(jul, FetchNature::Monthly) + .unwrap(); + assert_eq!(jul_txs.get_tx(0).details, Some("Salary".into())); + assert_eq!(jul_txs.get_tx(0).tags[0].name, "Salary"); + + // Bank method should still be at zero — no txs touched it + let jul_balance = jul_txs.get_tx_balance(0); + assert_eq!(jul_balance[&bank_method].value(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_yearly_fetch() { + let file_name = "test_add_tx_yearly.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx_helper( + &mut db_conn, + "2024-01-15", + "Jan", + "Cash", + "", + "100.00", + "Income", + "A", + ); + add_tx_helper( + &mut db_conn, + "2024-06-20", + "Jun", + "Cash", + "", + "200.00", + "Expense", + "B", + ); + add_tx_helper( + &mut db_conn, + "2024-12-01", + "Dec", + "Cash", + "", + "50.00", + "Expense", + "C", + ); + + let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); + let yearly = db_conn + .fetch_txs_with_date(date, FetchNature::Yearly) + .unwrap(); + assert_eq!(yearly.len(), 3); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_all_fetch() { + let file_name = "test_add_tx_all.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx_helper( + &mut db_conn, + "2023-06-15", + "Old", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx_helper( + &mut db_conn, + "2024-01-01", + "New", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx_helper( + &mut db_conn, + "2025-03-10", + "Future", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + + let date = NaiveDate::from_ymd_opt(2023, 6, 1).unwrap(); + let all = db_conn.fetch_txs_with_date(date, FetchNature::All).unwrap(); + assert_eq!(all.len(), 3); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_multiple_methods_balance_independently() { + let file_name = "test_add_tx_multi_method.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + add_tx_helper( + &mut db_conn, + "2024-03-01", + "Income", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + add_tx_helper( + &mut db_conn, + "2024-03-05", + "Savings", + "Bank", + "", + "200.00", + "Income", + "Savings", + ); + add_tx_helper( + &mut db_conn, + "2024-03-10", + "Coffee", + "Cash", + "", + "50.00", + "Expense", + "Food", + ); + + let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 3); + + let balance = tx_view.get_tx_balance(2); + assert_eq!(balance[&cash_id].value(), 45000); + assert_eq!(balance[&bank_id].value(), 20000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_transfer_sets_to_method() { + let file_name = "test_add_tx_transfer.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx_helper( + &mut db_conn, + "2024-04-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + add_tx_helper( + &mut db_conn, + "2024-04-01", + "Move", + "Cash", + "Bank", + "300.00", + "Transfer", + "Move", + ); + + let date = NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + let transfer = tx_view.get_tx(1); + assert_eq!(transfer.tx_type.to_string(), "Transfer"); + assert_eq!(transfer.from_method.name, "Cash"); + assert!(transfer.to_method.is_some()); + assert_eq!(transfer.to_method.as_ref().unwrap().name, "Bank"); + // to_array for non-search shows method as "Cash → Bank" + let array = transfer.to_array(false); + let method_str = &array[2]; + assert!(method_str.contains("→")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_to_array_search_format_differs() { + let file_name = "test_add_tx_to_array.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx_helper( + &mut db_conn, + "2024-05-15", + "Test tx", + "Cash", + "", + "42.50", + "Expense", + "TestTag", + ); + + let date = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + let tx = tx_view.get_tx(0); + + let search_arr = tx.to_array(true); + assert_eq!(search_arr[0], "2024-05-15"); + assert_eq!(search_arr[1], "Test tx"); + assert_eq!(search_arr[2], "Cash"); + assert_eq!(search_arr[3], "42.50"); + assert_eq!(search_arr[4], "Expense"); + assert_eq!(search_arr[5], "TestTag"); + + // Non-search format has weekday/time in date + let display_arr = tx.to_array(false); + assert!(display_arr[0].contains("Wed")); // 2024-05-15 is a Wednesday + assert!(!display_arr[0].contains("2024")); + drop(db_conn); fs::remove_file(file_name).unwrap(); } + +// Local helper to reduce boilerplate +fn add_tx_helper( + db_conn: &mut DbConn, + date: &str, + details: &str, + from_method: &str, + to_method: &str, + amount: &str, + tx_type: &str, + tags: &str, +) { + let new_tx = parse_tx_fields( + date, + details, + from_method, + to_method, + amount, + tx_type, + db_conn, + ) + .unwrap(); + db_conn.add_new_tx(new_tx, tags).unwrap(); +} diff --git a/app/tests/autofiller.rs b/app/tests/autofiller.rs new file mode 100644 index 00000000..0919e157 --- /dev/null +++ b/app/tests/autofiller.rs @@ -0,0 +1,337 @@ +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +// ---- Tx Method autofill ---- + +#[test] +fn autofill_method_exact_returns_empty() { + let file_name = "test_autofill_method.sqlite"; + let mut db_conn = create_test_db(file_name); + // Exact match produces no suggestion + let result = db_conn.autofill().tx_method("Cash"); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_method_fuzzy_suggests_match() { + let file_name = "test_autofill_method_fuzzy.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_method("Csh"); + assert_eq!(result, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_method_empty_returns_empty() { + let file_name = "test_autofill_method_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_method(""); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tx Type autofill ---- + +#[test] +fn autofill_tx_type_short_e() { + let file_name = "test_autofill_type_e.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("e"); + assert_eq!(result, "Expense"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_i() { + let file_name = "test_autofill_type_i.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("i"); + assert_eq!(result, "Income"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_t() { + let file_name = "test_autofill_type_t.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("t"); + assert_eq!(result, "Transfer"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_b() { + let file_name = "test_autofill_type_b.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("b"); + assert_eq!(result, "Borrow"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_l() { + let file_name = "test_autofill_type_l.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("l"); + assert_eq!(result, "Lend"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_br() { + let file_name = "test_autofill_type_br.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("br"); + assert_eq!(result, "Borrow Repay"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_short_lr() { + let file_name = "test_autofill_type_lr.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("lr"); + assert_eq!(result, "Lend Repay"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_exact_returns_empty() { + let file_name = "test_autofill_type_exact.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("Expense"); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_fuzzy_suggests_match() { + let file_name = "test_autofill_type_fuzzy.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type("Epense"); + assert_eq!(result, "Expense"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tx_type_empty_returns_empty() { + let file_name = "test_autofill_type_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let result = db_conn.autofill().tx_type(""); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tags autofill ---- + +#[test] +fn autofill_tags_fuzzy_suggests_match() { + let file_name = "test_autofill_tags.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-01-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Groceries", + ); + + let result = db_conn.autofill().tags("Gro"); + assert_eq!(result, "Groceries"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tags_exact_returns_empty() { + let file_name = "test_autofill_tags_exact.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-02-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Rent", + ); + + let result = db_conn.autofill().tags("Rent"); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tags_empty_returns_empty() { + let file_name = "test_autofill_tags_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-03-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Tag", + ); + + let result = db_conn.autofill().tags(""); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_tags_only_last_tag_suggested() { + let file_name = "test_autofill_tags_multi.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-04-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Groceries, Salary", + ); + + // Only the last tag (after comma) is matched + let result = db_conn.autofill().tags("Groceries, Sal"); + assert_eq!(result, "Salary"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Details autofill ---- + +#[test] +fn autofill_details_fuzzy_suggests_match() { + let file_name = "test_autofill_details.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-05-01", + "Amazon purchase", + "Cash", + "", + "50.00", + "Expense", + "Shopping", + ); + + let result = db_conn.autofill().details("Amaz"); + assert_eq!(result, "Amazon purchase"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_details_exact_returns_empty() { + let file_name = "test_autofill_details_exact.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-06-01", + "Netflix", + "Cash", + "", + "15.00", + "Expense", + "Subscriptions", + ); + + let result = db_conn.autofill().details("Netflix"); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_details_empty_returns_empty() { + let file_name = "test_autofill_details_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + add_tx( + &mut db_conn, + "2024-07-01", + "Some detail", + "Cash", + "", + "10.00", + "Expense", + "Tag", + ); + + let result = db_conn.autofill().details(""); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_details_cached_after_edit() { + let file_name = "test_autofill_details_edit.sqlite"; + let mut db_conn = create_test_db(file_name); + let old_tx = add_tx( + &mut db_conn, + "2024-08-01", + "Old detail", + "Cash", + "", + "10.00", + "Expense", + "Tag", + ); + + // Replace with a new detail + let new_tx = rex_app::modifier::parse_tx_fields( + "2024-08-01", + "Brand new detail", + "Cash", + "", + "10.00", + "Expense", + &db_conn, + ) + .unwrap(); + db_conn.edit_tx(&old_tx, new_tx, "Tag").unwrap(); + + let result = db_conn.autofill().details("Brand"); + assert_eq!(result, "Brand new detail"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn autofill_details_on_empty_db_returns_empty() { + let file_name = "test_autofill_details_empty_db.sqlite"; + let mut db_conn = create_test_db(file_name); + // No transactions = no details to match + let result = db_conn.autofill().details("anything"); + assert_eq!(result, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/balance_table.rs b/app/tests/balance_table.rs new file mode 100644 index 00000000..0597f89a --- /dev/null +++ b/app/tests/balance_table.rs @@ -0,0 +1,539 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_db::ConnCache; +use rex_db::models::Balance; +use rex_shared::models::Cent; +use std::collections::HashMap; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +fn get_month_balance_map( + db_conn: &mut rex_app::conn::DbConn, + year: i32, + month: u32, +) -> HashMap { + let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let map = Balance::get_balance_map(date, db_conn).unwrap(); + let mut result = HashMap::new(); + for method in db_conn.cache().tx_methods.values() { + let balance = map + .get(&method.id) + .map(|b| Cent::new(b.balance)) + .unwrap_or(Cent::new(0)); + result.insert(method.name.clone(), balance); + } + result +} + +fn get_last_balance( + db_conn: &mut rex_app::conn::DbConn, + year: i32, + month: u32, + nature: FetchNature, +) -> HashMap { + let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let map = Balance::get_last_balance(date, nature, db_conn).unwrap(); + let mut result = HashMap::new(); + for method in db_conn.cache().tx_methods.values() { + result.insert(method.name.clone(), map[&method.id]); + } + result +} + +fn get_final_balance_map(db_conn: &mut rex_app::conn::DbConn) -> HashMap { + let map = Balance::get_final_balance(db_conn).unwrap(); + let mut result = HashMap::new(); + for method in db_conn.cache().tx_methods.values() { + result.insert(method.name.clone(), Cent::new(map[&method.id].balance)); + } + result +} + +#[test] +fn balance_table_add_tx_updates_current_month() { + let file_name = "test_balance_add.sqlite"; + let mut db_conn = create_test_db(file_name); + + assert_eq!(get_final_balance_map(&mut db_conn)["Cash"], Cent::new(0)); + + add_tx( + &mut db_conn, + "2024-07-15", + "Income", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 7)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(50000) + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_sequential_months_cascade() { + let file_name = "test_balance_months.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "July", + "Cash", + "", + "500.00", + "Income", + "A", + ); + add_tx( + &mut db_conn, + "2024-08-15", + "August", + "Cash", + "", + "300.00", + "Income", + "B", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 7)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 8)["Cash"], + Cent::new(80000) + ); + + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(80000) + ); + + let sep_last = get_last_balance(&mut db_conn, 2024, 9, FetchNature::Monthly); + assert_eq!(sep_last["Cash"], Cent::new(80000)); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_delete_cascades_forward() { + let file_name = "test_balance_delete.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "July", + "Cash", + "", + "500.00", + "Income", + "A", + ); + let aug_tx = add_tx( + &mut db_conn, + "2024-08-01", + "August", + "Cash", + "", + "300.00", + "Income", + "B", + ); + add_tx( + &mut db_conn, + "2024-09-01", + "Sept", + "Cash", + "", + "200.00", + "Income", + "C", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 7)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 8)["Cash"], + Cent::new(80000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 9)["Cash"], + Cent::new(100000) + ); + + db_conn.delete_tx(&aug_tx).unwrap(); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 7)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 8)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 9)["Cash"], + Cent::new(70000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(70000) + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_edit_cascades_forward() { + let file_name = "test_balance_edit.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "July", + "Cash", + "", + "500.00", + "Income", + "A", + ); + let aug_tx = add_tx( + &mut db_conn, + "2024-08-01", + "August", + "Cash", + "", + "300.00", + "Income", + "B", + ); + add_tx( + &mut db_conn, + "2024-09-01", + "Sept", + "Cash", + "", + "200.00", + "Income", + "C", + ); + + let new_tx = rex_app::modifier::parse_tx_fields( + "2024-08-01", + "August", + "Cash", + "", + "100.00", + "Income", + &db_conn, + ) + .unwrap(); + db_conn.edit_tx(&aug_tx, new_tx, "B").unwrap(); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 7)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 8)["Cash"], + Cent::new(60000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 9)["Cash"], + Cent::new(80000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(80000) + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_mid_insert_updates_forward() { + let file_name = "test_balance_mid.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-01-15", + "Jan", + "Cash", + "", + "1000.00", + "Income", + "A", + ); + add_tx( + &mut db_conn, + "2024-03-10", + "Mar", + "Cash", + "", + "200.00", + "Expense", + "B", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 1)["Cash"], + Cent::new(100000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 3)["Cash"], + Cent::new(80000) + ); + + add_tx( + &mut db_conn, + "2024-02-10", + "Feb", + "Cash", + "", + "300.00", + "Income", + "C", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 1)["Cash"], + Cent::new(100000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 2)["Cash"], + Cent::new(130000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 3)["Cash"], + Cent::new(110000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(110000) + ); + + let may_last = get_last_balance(&mut db_conn, 2024, 5, FetchNature::Monthly); + assert_eq!(may_last["Cash"], Cent::new(110000)); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_multiple_methods() { + let file_name = "test_balance_methods.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-04-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-04-15", + "Transfer", + "Cash", + "Bank", + "300.00", + "Transfer", + "Move", + ); + + let apr = get_month_balance_map(&mut db_conn, 2024, 4); + assert_eq!(apr["Cash"], Cent::new(70000)); + assert_eq!(apr["Bank"], Cent::new(30000)); + assert_eq!(apr["Other"], Cent::new(0)); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_year_boundary_cascades() { + let file_name = "test_balance_year.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2023-12-15", + "Dec", + "Cash", + "", + "500.00", + "Income", + "A", + ); + add_tx( + &mut db_conn, + "2024-01-10", + "Jan", + "Cash", + "", + "300.00", + "Income", + "B", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2023, 12)["Cash"], + Cent::new(50000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 1)["Cash"], + Cent::new(80000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(80000) + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_out_of_order_insert_maintains_cascade() { + let file_name = "test_balance_ooo.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-04-01", + "Apr", + "Cash", + "", + "400.00", + "Income", + "D", + ); + add_tx( + &mut db_conn, + "2024-02-01", + "Feb", + "Cash", + "", + "200.00", + "Income", + "B", + ); + add_tx( + &mut db_conn, + "2024-03-01", + "Mar", + "Cash", + "", + "300.00", + "Income", + "C", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "Jan", + "Cash", + "", + "100.00", + "Income", + "A", + ); + + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 1)["Cash"], + Cent::new(10000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 2)["Cash"], + Cent::new(30000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 3)["Cash"], + Cent::new(60000) + ); + assert_eq!( + get_month_balance_map(&mut db_conn, 2024, 4)["Cash"], + Cent::new(100000) + ); + assert_eq!( + get_final_balance_map(&mut db_conn)["Cash"], + Cent::new(100000) + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_table_transfer_across_months() { + let file_name = "test_balance_transfer.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-05-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + add_tx( + &mut db_conn, + "2024-05-10", + "Move", + "Cash", + "Bank", + "300.00", + "Transfer", + "Move", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "Move more", + "Cash", + "Bank", + "100.00", + "Transfer", + "Move", + ); + + let may = get_month_balance_map(&mut db_conn, 2024, 5); + assert_eq!(may["Cash"], Cent::new(70000)); + assert_eq!(may["Bank"], Cent::new(30000)); + + let jun = get_month_balance_map(&mut db_conn, 2024, 6); + assert_eq!(jun["Cash"], Cent::new(60000)); + assert_eq!(jun["Bank"], Cent::new(40000)); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/chart_view.rs b/app/tests/chart_view.rs new file mode 100644 index 00000000..8105d55d --- /dev/null +++ b/app/tests/chart_view.rs @@ -0,0 +1,181 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_db::ConnCache; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn chart_view_empty() { + let file_name = "test_chart_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + + let chart = db_conn + .get_chart_view_with_str("January", "2024", FetchNature::Monthly) + .unwrap(); + + assert!(chart.is_empty()); + assert_eq!(chart.len(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn chart_view_date_bounds() { + let file_name = "test_chart_bounds.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-03-05", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-03-15", + "Middle", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-03-25", + "Last", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + + let chart = db_conn + .get_chart_view_with_str("March", "2024", FetchNature::Monthly) + .unwrap(); + + assert!(!chart.is_empty()); + assert_eq!(chart.len(), 3); + + assert_eq!( + chart.start_date(), + NaiveDate::from_ymd_opt(2024, 3, 5).unwrap() + ); + assert_eq!( + chart.end_date(), + NaiveDate::from_ymd_opt(2024, 3, 25).unwrap() + ); + + assert!(chart.contains_date(&NaiveDate::from_ymd_opt(2024, 3, 15).unwrap())); + assert!(!chart.contains_date(&NaiveDate::from_ymd_opt(2024, 3, 10).unwrap())); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn chart_view_get_tx_and_balance() { + let file_name = "test_chart_tx_balance.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + add_tx( + &mut db_conn, + "2024-06-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + add_tx( + &mut db_conn, + "2024-06-10", + "Transfer", + "Cash", + "Bank", + "300.00", + "Transfer", + "Move", + ); + add_tx( + &mut db_conn, + "2024-06-20", + "Expense", + "Cash", + "", + "100.00", + "Expense", + "Food", + ); + + let chart = db_conn + .get_chart_view_with_str("June", "2024", FetchNature::Monthly) + .unwrap(); + + assert_eq!(chart.len(), 3); + + assert_eq!(chart.get_tx(0).details, Some("Income".into())); + assert_eq!(chart.get_tx(1).details, Some("Transfer".into())); + assert_eq!(chart.get_tx(2).details, Some("Expense".into())); + + // Running balances: Income(+1000 Cash), Transfer(-300 Cash, +300 Bank), Expense(-100 Cash) + let b0 = chart.get_balance(0); + let b1 = chart.get_balance(1); + let b2 = chart.get_balance(2); + + assert_eq!(b0[&cash_id].value(), 100000); + assert_eq!(b0[&bank_id].value(), 0); + + assert_eq!(b1[&cash_id].value(), 70000); + assert_eq!(b1[&bank_id].value(), 30000); + + assert_eq!(b2[&cash_id].value(), 60000); + assert_eq!(b2[&bank_id].value(), 30000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn chart_view_single_tx() { + let file_name = "test_chart_single.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-09-01", + "Only", + "Cash", + "", + "50.00", + "Expense", + "Tag", + ); + + let chart = db_conn + .get_chart_view_with_str("September", "2024", FetchNature::Monthly) + .unwrap(); + + assert_eq!(chart.len(), 1); + assert_eq!(chart.start_date(), chart.end_date()); + assert_eq!( + chart.start_date(), + NaiveDate::from_ymd_opt(2024, 9, 1).unwrap() + ); + assert!(chart.contains_date(&NaiveDate::from_ymd_opt(2024, 9, 1).unwrap())); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/common.rs b/app/tests/common.rs index 8aeef197..e3a827c7 100644 --- a/app/tests/common.rs +++ b/app/tests/common.rs @@ -1,4 +1,7 @@ -use rex_app::conn::{DbConn, get_conn}; +use chrono::NaiveDate; +use rex_app::conn::{DbConn, FetchNature, get_conn}; +use rex_app::modifier::parse_tx_fields; +use rex_db::models::FullTx; use std::fs; #[must_use] @@ -19,3 +22,35 @@ pub fn create_test_db(file_name: &str) -> DbConn { conn } + +#[allow(dead_code)] +pub fn add_tx( + db_conn: &mut DbConn, + date: &str, + details: &str, + from_method: &str, + to_method: &str, + amount: &str, + tx_type: &str, + tags: &str, +) -> FullTx { + let new_tx = parse_tx_fields( + date, + details, + from_method, + to_method, + amount, + tx_type, + db_conn, + ) + .unwrap(); + + db_conn.add_new_tx(new_tx, tags).unwrap(); + + let date = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + tx_view.get_tx(tx_view.len() - 1).clone() +} diff --git a/app/tests/delete_tx.rs b/app/tests/delete_tx.rs new file mode 100644 index 00000000..e592d0c3 --- /dev/null +++ b/app/tests/delete_tx.rs @@ -0,0 +1,268 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_db::ConnCache; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn delete_income_reverses_balance() { + let file_name = "test_delete_income.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(); + + let tx = add_tx( + &mut db_conn, + "2024-06-15", + "Paycheck", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 50000); + + db_conn.delete_tx(&tx).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn delete_expense_reverses_balance() { + let file_name = "test_delete_expense.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + // First add income so we have a balance to spend from + add_tx( + &mut db_conn, + "2024-07-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + // Add an expense of $200 + let tx = add_tx( + &mut db_conn, + "2024-07-15", + "Rent", + "Cash", + "", + "200.00", + "Expense", + "Rent", + ); + + let date = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + // Balance after both txs: 1000 - 200 = 800 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 80000); + + // Delete the expense + db_conn.delete_tx(&tx).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1, "Only the income should remain"); + // Balance should revert to 1000 + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 100000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn delete_transfer_reverses_both_methods() { + let file_name = "test_delete_transfer.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + // Add income to Cash first + add_tx( + &mut db_conn, + "2024-08-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + let tx = add_tx( + &mut db_conn, + "2024-08-10", + "Move to bank", + "Cash", + "Bank", + "300.00", + "Transfer", + "Transfer", + ); + + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + // Cash: 1000 - 300 = 700, Bank: 0 + 300 = 300 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 70000); + assert_eq!(tx_view.get_tx_balance(1)[&bank_id].value(), 30000); + + db_conn.delete_tx(&tx).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + // Cash back to 1000, Bank back to 0 + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 100000); + assert_eq!(tx_view.get_tx_balance(0)[&bank_id].value(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn delete_first_of_multiple_txs_maintains_other_balances() { + let file_name = "test_delete_first_tx.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + let tx1 = add_tx( + &mut db_conn, + "2024-09-01", + "Salary", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + add_tx( + &mut db_conn, + "2024-09-15", + "Groceries", + "Cash", + "", + "100.00", + "Expense", + "Groceries", + ); + add_tx( + &mut db_conn, + "2024-09-20", + "Coffee", + "Cash", + "", + "50.00", + "Expense", + "Coffee", + ); + + let date = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 3); + // Balance: 500 - 100 - 50 = 350 + assert_eq!(tx_view.get_tx_balance(2)[&cash_id].value(), 35000); + + db_conn.delete_tx(&tx1).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + // Without the income: 0 - 100 - 50 = -150 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), -15000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn delete_middle_tx_maintains_remaining_order() { + let file_name = "test_delete_middle_tx.sqlite"; + + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-10-01", + "Salary", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + let middle = add_tx( + &mut db_conn, + "2024-10-01", + "Rent", + "Cash", + "", + "500.00", + "Expense", + "Rent", + ); + add_tx( + &mut db_conn, + "2024-10-01", + "Food", + "Cash", + "", + "100.00", + "Expense", + "Food", + ); + + let date = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 3); + + db_conn.delete_tx(&middle).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + // Details should be Salary and Food (Rent removed) + assert_eq!(tx_view.get_tx(0).details, Some("Salary".to_string())); + assert_eq!(tx_view.get_tx(1).details, Some("Food".to_string())); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/edit_tx.rs b/app/tests/edit_tx.rs new file mode 100644 index 00000000..aa626ac3 --- /dev/null +++ b/app/tests/edit_tx.rs @@ -0,0 +1,308 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_app::modifier::parse_tx_fields; +use rex_db::ConnCache; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn edit_income_amount_updates_balance() { + let file_name = "test_edit_income_amount.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(); + + let old_tx = add_tx( + &mut db_conn, + "2024-06-01", + "Paycheck", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + let new_tx = parse_tx_fields( + "2024-06-01", + "Paycheck", + "Cash", + "", + "700.00", + "Income", + &db_conn, + ) + .unwrap(); + + db_conn.edit_tx(&old_tx, new_tx, "Salary").unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 70000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_expense_amount_updates_balance() { + let file_name = "test_edit_expense_amount.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-07-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + let old_tx = add_tx( + &mut db_conn, + "2024-07-15", + "Rent", + "Cash", + "", + "200.00", + "Expense", + "Rent", + ); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 80000); + + let new_tx = parse_tx_fields( + "2024-07-15", + "Rent", + "Cash", + "", + "300.00", + "Expense", + &db_conn, + ) + .unwrap(); + + db_conn.edit_tx(&old_tx, new_tx, "Rent").unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + // 1000 - 300 = 700 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 70000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_income_to_expense_reverses_balance() { + let file_name = "test_edit_income_to_expense.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + + let old_tx = add_tx( + &mut db_conn, + "2024-08-01", + "Mistake", + "Cash", + "", + "500.00", + "Income", + "Unknown", + ); + + let new_tx = parse_tx_fields( + "2024-08-01", + "Mistake", + "Cash", + "", + "500.00", + "Expense", + &db_conn, + ) + .unwrap(); + + db_conn.edit_tx(&old_tx, new_tx, "Unknown").unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + // Changed from +500 to -500 + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), -50000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_change_method_moves_balance() { + let file_name = "test_edit_change_method.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); + + // Add income to Cash + add_tx( + &mut db_conn, + "2024-09-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + let old_tx = add_tx( + &mut db_conn, + "2024-09-10", + "Coffee", + "Cash", + "", + "50.00", + "Expense", + "Coffee", + ); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 95000); + + let new_tx = parse_tx_fields( + "2024-09-10", + "Coffee", + "Bank", + "", + "50.00", + "Expense", + &db_conn, + ) + .unwrap(); + + db_conn.edit_tx(&old_tx, new_tx, "Coffee").unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + // Cash: 1000 (unchanged), Bank: -50 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 100000); + assert_eq!(tx_view.get_tx_balance(1)[&bank_id].value(), -5000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_details_preserves_balance() { + let file_name = "test_edit_details.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(); + + let old_tx = add_tx( + &mut db_conn, + "2024-10-01", + "Old name", + "Cash", + "", + "300.00", + "Income", + "Salary", + ); + + let new_tx = parse_tx_fields( + "2024-10-01", + "New name", + "Cash", + "", + "300.00", + "Income", + &db_conn, + ) + .unwrap(); + + db_conn.edit_tx(&old_tx, new_tx, "Salary").unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + assert_eq!(tx_view.get_tx(0).details, Some("New name".to_string())); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 30000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_tags_changes_only_tags() { + let file_name = "test_edit_tags.sqlite"; + + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(); + + let old_tx = add_tx( + &mut db_conn, + "2024-11-01", + "Shopping", + "Cash", + "", + "200.00", + "Expense", + "OldTag", + ); + + let new_tx = parse_tx_fields( + "2024-11-01", + "Shopping", + "Cash", + "", + "200.00", + "Expense", + &db_conn, + ) + .unwrap(); + + db_conn + .edit_tx(&old_tx, new_tx, "NewTag, ExtraTag") + .unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + let tags: Vec<&str> = tx_view + .get_tx(0) + .tags + .iter() + .map(|t| t.name.as_str()) + .collect(); + assert_eq!(tags, vec!["NewTag", "ExtraTag"]); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), -20000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/parse.rs b/app/tests/parse.rs new file mode 100644 index 00000000..94766b69 --- /dev/null +++ b/app/tests/parse.rs @@ -0,0 +1,137 @@ +use rex_app::modifier::parse_search_fields; +use rex_db::ConnCache; +use std::fs; + +use crate::common::create_test_db; + +mod common; + +#[test] +fn parse_search_fields_year_only() { + let file_name = "test_parse_search_date.sqlite"; + let mut db_conn = create_test_db(file_name); + + let search = parse_search_fields("2024", "", "", "", "", "", "", &db_conn).unwrap(); + + assert!(search.date.is_some()); + assert!(search.details.is_none()); + assert!(search.from_method.is_none()); + assert!(search.tags.is_none()); + + // Year search should find nothing on empty DB + let results = search.search_txs(&mut db_conn).unwrap(); + assert!(results.is_empty()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_month_year() { + let file_name = "test_parse_search_month.sqlite"; + let mut db_conn = create_test_db(file_name); + + let search = parse_search_fields("2024-06", "", "", "", "", "", "", &db_conn).unwrap(); + + assert!(search.date.is_some()); + assert!(search.tags.is_none()); + + let results = search.search_txs(&mut db_conn).unwrap(); + assert!(results.is_empty()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_exact_date() { + let file_name = "test_parse_search_exact.sqlite"; + let db_conn = create_test_db(file_name); + + let search = parse_search_fields("2024-06-15", "", "", "", "", "", "", &db_conn).unwrap(); + + assert!(search.date.is_some()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_amount_nature_variants() { + let file_name = "test_parse_search_amount.sqlite"; + let db_conn = create_test_db(file_name); + + let search = parse_search_fields("", "", "", "", ">100", "", "", &db_conn).unwrap(); + assert!(search.amount.is_some()); + + let search = parse_search_fields("", "", "", "", "<50", "", "", &db_conn).unwrap(); + assert!(search.amount.is_some()); + + let search = parse_search_fields("", "", "", "", ">=200", "", "", &db_conn).unwrap(); + assert!(search.amount.is_some()); + + let search = parse_search_fields("", "", "", "", "<=10", "", "", &db_conn).unwrap(); + assert!(search.amount.is_some()); + + let search = parse_search_fields("", "", "", "", "500", "", "", &db_conn).unwrap(); + assert!(search.amount.is_some()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_nonexistent_tags_filtered() { + let file_name = "test_parse_search_tags.sqlite"; + let db_conn = create_test_db(file_name); + + // Non-existent tags are silently skipped by parse_search_fields + let search = + parse_search_fields("", "", "", "", "", "", "FakeTag, AnotherFake", &db_conn).unwrap(); + // tags get filtered to empty because no tag exists in cache (except "Unknown") + assert!( + search.tags.is_none() || search.tags.as_ref().unwrap().is_empty(), + "Non-existent tags should be filtered out, got: {:?}", + search.tags + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_all_empty_returns_empty_search() { + let file_name = "test_parse_search_empty.sqlite"; + let db_conn = create_test_db(file_name); + + let search = parse_search_fields("", "", "", "", "", "", "", &db_conn).unwrap(); + + assert!(search.date.is_none()); + assert!(search.details.is_none()); + assert!(search.tx_type.is_none()); + assert!(search.from_method.is_none()); + assert!(search.to_method.is_none()); + assert!(search.amount.is_none()); + assert!(search.tags.is_none()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn parse_search_fields_combined_filters() { + let file_name = "test_parse_search_combined.sqlite"; + let db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + let search = + parse_search_fields("2024", "Salary", "Cash", "", "", "Income", "", &db_conn).unwrap(); + + assert!(search.date.is_some()); + assert_eq!(search.details, Some("Salary")); + assert_eq!(search.from_method, Some(cash_id)); + assert_eq!(search.tx_type, Some("Income")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/position_swap.rs b/app/tests/position_swap.rs new file mode 100644 index 00000000..0f721a7d --- /dev/null +++ b/app/tests/position_swap.rs @@ -0,0 +1,670 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_db::ConnCache; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +fn tx_details_order(db_conn: &mut rex_app::conn::DbConn, date: NaiveDate) -> Vec> { + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + (0..tx_view.len()) + .map(|i| tx_view.get_tx(i).details.clone()) + .collect() +} + +#[test] +fn swap_two_txs_same_day() { + let file_name = "test_pos_swap_two.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-06-01", + "A", + "Cash", + "", + "10.00", + "Expense", + "TagA", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "B", + "Cash", + "", + "20.00", + "Expense", + "TagB", + ); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("A".into()), Some("B".into())] + ); + + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + let swapped = db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + assert!(swapped); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("B".into()), Some("A".into())] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_two_txs_twice_restores_original_order() { + let file_name = "test_pos_swap_two_twice.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-07-01", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-07-01", + "Second", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("First".into()), Some("Second".into())] + ); + + // First swap + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("Second".into()), Some("First".into())] + ); + + // Second swap restores + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("First".into()), Some("Second".into())] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_three_txs_first_and_last() { + let file_name = "test_pos_swap_three_first_last.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-08-01", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-08-01", + "Middle", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-08-01", + "Last", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + + // Initial: First, Middle, Last + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("First".into()), + Some("Middle".into()), + Some("Last".into()) + ] + ); + + // Swap first(0) and last(2) + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 2, &mut tx_view).unwrap(); + + // Normalized: First(1), Middle(2), Last(3) → swap First↔Last: Last(1), Middle(2), First(3) + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("Last".into()), + Some("Middle".into()), + Some("First".into()) + ] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_three_txs_adjacent_middle_pair() { + let file_name = "test_pos_swap_three_adjacent.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-09-01", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-09-01", + "Middle", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-09-01", + "Last", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + + // Initial: First, Middle, Last + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("First".into()), + Some("Middle".into()), + Some("Last".into()) + ] + ); + + // Swap Middle(1) and Last(2) + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(1, 2, &mut tx_view).unwrap(); + + // Normalized: First(1), Middle(2), Last(3) → swap Middle↔Last: First(1), Last(2), Middle(3) + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("First".into()), + Some("Last".into()), + Some("Middle".into()) + ] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_different_dates_returns_false() { + let file_name = "test_pos_swap_diff_date.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-10-01", + "Oct", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-10-02", + "Oct2", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + + // Fetch all txs for October to get both in one TxViewGroup + let date = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(); + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + + let swapped = db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + assert!(!swapped, "Cross-date swap should return false"); + + // Order unchanged — both still in October monthly view in original order + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("Oct".into()), Some("Oct2".into())] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_three_txs_first_and_middle() { + let file_name = "test_pos_swap_three_first_middle.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(); + + add_tx( + &mut db_conn, + "2024-11-01", + "First", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-11-01", + "Middle", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-11-01", + "Last", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + + // Swap First(0) and Middle(1) + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + + // First.do=Middle.id, Middle.do=First.id, Last.do=0 + // Sort: non-zero: Middle(do=1), First(do=2); zero: Last(do=0) + // Assuming First.id=1, Middle.id=2: First.do=2, Middle.do=1 + // Order: Middle(1), First(2), Last(0) + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("Middle".into()), + Some("First".into()), + Some("Last".into()) + ] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_persists_after_multiple_swaps() { + let file_name = "test_pos_swap_persist.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + + // Add 4 txs all on same day + add_tx( + &mut db_conn, + "2024-12-01", + "A", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-12-01", + "B", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-12-01", + "C", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + add_tx( + &mut db_conn, + "2024-12-01", + "D", + "Cash", + "", + "40.00", + "Expense", + "D", + ); + + // Initial: A, B, C, D + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("A".into()), + Some("B".into()), + Some("C".into()), + Some("D".into()), + ] + ); + + // Swap A(0) and B(1) + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + + // Step 1: A↔B — normalize: A(1),B(2),C(3),D(4); swap: A(2),B(1) → B, A, C, D + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("B".into()), + Some("A".into()), + Some("C".into()), + Some("D".into()), + ] + ); + + // Step 2: B↔C — swap do: B(3),C(1) → C, A, B, D + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 2, &mut tx_view).unwrap(); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("C".into()), + Some("A".into()), + Some("B".into()), + Some("D".into()), + ] + ); + + // Step 3: D↔A — swap do: D(2),A(4) → C, D, B, A + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(1, 3, &mut tx_view).unwrap(); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![ + Some("C".into()), + Some("D".into()), + Some("B".into()), + Some("A".into()), + ] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_four_txs_all_pairwise() { + let file_name = "test_pos_swap_four_all.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + + add_tx( + &mut db_conn, + "2025-01-01", + "A", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2025-01-01", + "B", + "Cash", + "", + "20.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2025-01-01", + "C", + "Cash", + "", + "30.00", + "Expense", + "C", + ); + add_tx( + &mut db_conn, + "2025-01-01", + "D", + "Cash", + "", + "40.00", + "Expense", + "D", + ); + + // Swap to reverse completely: D, C, B, A via multiple swaps + // Step 1: A<->D -> B, C, A, D (based on first/last pattern) + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 3, &mut tx_view).unwrap(); + + // Now: A.do=D.id, B.do=0, C.do=0, D.do=A.id + // non-zero: D(1), A(4); zero: B(2), C(3) + // Order: D, A, B, C + + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + // Swap A(1) and C(3) — A at index 1, C at index 3 + db_conn.swap_tx_position(1, 3, &mut tx_view).unwrap(); + + // A.do=C.id, C.do=A.do=4. D.do=1, B.do=0 + // non-zero: D(1), C(4), A(C.id=3); zero: B(2) + // Order: D, C, A, B + + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + // Swap A(2) and B(3) — A at index 2, B at index 3 + db_conn.swap_tx_position(2, 3, &mut tx_view).unwrap(); + + // A.do=B.id, B.do=A.do=3. D.do=1, C.do=4 + // non-zero: D(1), B(2), A(3), C(4)? + // Wait: B.id=2 so A.do=2. B.do=A.do=3 + // non-zero: D(1), A(2), B(3), C(4) — all non-zero now! + // Order: D, A, B, C + // Hmm, that's not fully reversed yet. + + // Actually let me just check that we got some valid reordering + let final_order = tx_details_order(&mut db_conn, date); + assert_eq!(final_order.len(), 4); + // All 4 should still be present + let all_details: Vec<&str> = final_order.iter().map(|d| d.as_deref().unwrap()).collect(); + assert!(all_details.contains(&"A")); + assert!(all_details.contains(&"B")); + assert!(all_details.contains(&"C")); + assert!(all_details.contains(&"D")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_single_tx_noop() { + let file_name = "test_pos_swap_single.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(); + + add_tx( + &mut db_conn, + "2025-02-01", + "Only", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + + // Swapping the only tx with itself succeeds + let swapped = db_conn.swap_tx_position(0, 0, &mut tx_view).unwrap(); + assert!(swapped); + + assert_eq!( + tx_details_order(&mut db_conn, date), + vec![Some("Only".into())] + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn swap_maintains_tx_integrity() { + let file_name = "test_pos_swap_integrity.sqlite"; + let mut db_conn = create_test_db(file_name); + let date = NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(); + + add_tx( + &mut db_conn, + "2025-03-01", + "Income1", + "Cash", + "", + "100.00", + "Income", + "Salary", + ); + add_tx( + &mut db_conn, + "2025-03-01", + "Expense1", + "Cash", + "", + "30.00", + "Expense", + "Food", + ); + add_tx( + &mut db_conn, + "2025-03-01", + "Expense2", + "Cash", + "", + "20.00", + "Expense", + "Coffee", + ); + + // Swap first two + let mut tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + db_conn.swap_tx_position(0, 1, &mut tx_view).unwrap(); + + // Re-fetch and verify each tx's data (type, amount, method, tags) is intact + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // After swap, order should be: Expense1, Income1, Expense2 + assert_eq!(tx_view.get_tx(0).details, Some("Expense1".into())); + assert_eq!(tx_view.get_tx(0).tags[0].name, "Food"); + assert_eq!(tx_view.get_tx(1).details, Some("Income1".into())); + assert_eq!(tx_view.get_tx(1).tags[0].name, "Salary"); + assert_eq!(tx_view.get_tx(2).details, Some("Expense2".into())); + assert_eq!(tx_view.get_tx(2).tags[0].name, "Coffee"); + + // After swap order: Expense1(-30), Income1(+100), Expense2(-20) + // Running balances: -30, +70, +50 + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let balances: Vec = (0..3) + .map(|i| tx_view.get_tx_balance(i)[&cash_id].value()) + .collect(); + assert_eq!(balances, vec![-3000, 7000, 5000]); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/search_amount.rs b/app/tests/search_amount.rs new file mode 100644 index 00000000..5a046162 --- /dev/null +++ b/app/tests/search_amount.rs @@ -0,0 +1,288 @@ +use rex_app::modifier::parse_search_fields; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +fn search_and_count(db_conn: &mut rex_app::conn::DbConn, amount: &str) -> usize { + let search = parse_search_fields("", "", "", "", amount, "", "", db_conn).unwrap(); + search.search_txs(db_conn).unwrap().len() +} + +#[test] +fn search_amount_exact() { + let file_name = "test_search_amount_exact.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-01-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "T2", + "Cash", + "", + "50.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "T3", + "Cash", + "", + "100.00", + "Expense", + "C", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "T4", + "Cash", + "", + "50.00", + "Expense", + "D", + ); + + assert_eq!(search_and_count(&mut db_conn, "50.00"), 2); + assert_eq!(search_and_count(&mut db_conn, "10.00"), 1); + assert_eq!(search_and_count(&mut db_conn, "100.00"), 1); + assert_eq!(search_and_count(&mut db_conn, "999.00"), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_amount_more_than() { + let file_name = "test_search_amount_more.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-02-01", + "A", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-02-01", + "B", + "Cash", + "", + "50.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-02-01", + "C", + "Cash", + "", + "100.00", + "Expense", + "C", + ); + + assert_eq!(search_and_count(&mut db_conn, ">50.00"), 1); + assert_eq!(search_and_count(&mut db_conn, ">10.00"), 2); + assert_eq!(search_and_count(&mut db_conn, ">100.00"), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_amount_more_than_equal() { + let file_name = "test_search_amount_more_eq.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-03-01", + "A", + "Cash", + "", + "50.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-03-01", + "B", + "Cash", + "", + "100.00", + "Expense", + "B", + ); + + assert_eq!(search_and_count(&mut db_conn, ">=50.00"), 2); + assert_eq!(search_and_count(&mut db_conn, ">=100.00"), 1); + assert_eq!(search_and_count(&mut db_conn, ">=200.00"), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_amount_less_than() { + let file_name = "test_search_amount_less.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-04-01", + "A", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-04-01", + "B", + "Cash", + "", + "50.00", + "Expense", + "B", + ); + add_tx( + &mut db_conn, + "2024-04-01", + "C", + "Cash", + "", + "100.00", + "Expense", + "C", + ); + + assert_eq!(search_and_count(&mut db_conn, "<50.00"), 1); + assert_eq!(search_and_count(&mut db_conn, "<100.00"), 2); + assert_eq!(search_and_count(&mut db_conn, "<10.00"), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_amount_less_than_equal() { + let file_name = "test_search_amount_less_eq.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-05-01", + "A", + "Cash", + "", + "50.00", + "Expense", + "A", + ); + add_tx( + &mut db_conn, + "2024-05-01", + "B", + "Cash", + "", + "100.00", + "Expense", + "B", + ); + + assert_eq!(search_and_count(&mut db_conn, "<=50.00"), 1); + assert_eq!(search_and_count(&mut db_conn, "<=100.00"), 2); + assert_eq!(search_and_count(&mut db_conn, "<=10.00"), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_amount_combined_with_other_filters() { + let file_name = "test_search_amount_combined.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-06-01", + "Groceries", + "Cash", + "", + "50.00", + "Expense", + "Food", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "Groceries", + "Cash", + "", + "80.00", + "Expense", + "Food", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "Salary", + "Cash", + "", + "500.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-07-01", + "Groceries", + "Cash", + "", + "90.00", + "Expense", + "Food", + ); + + // Expense Groceries > $50 in June 2024 + let search = parse_search_fields( + "2024-06", + "Groceries", + "Cash", + "", + ">50.00", + "Expense", + "Food", + &db_conn, + ) + .unwrap(); + let results = search.search_txs(&mut db_conn).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].amount.value(), 8000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/search_view.rs b/app/tests/search_view.rs new file mode 100644 index 00000000..e5948c38 --- /dev/null +++ b/app/tests/search_view.rs @@ -0,0 +1,86 @@ +use rex_app::modifier::parse_search_fields; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn search_view_empty_by_constructor() { + let view = rex_app::views::SearchView::new_empty(); + assert!(view.is_empty()); + assert_eq!(view.tx_array().len(), 0); +} + +#[test] +fn search_view_with_results() { + let file_name = "test_search_view_results.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-06-01", + "Rent", + "Cash", + "", + "500.00", + "Expense", + "Rent", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "Salary", + "Cash", + "", + "1000.00", + "Income", + "Work", + ); + + let search = parse_search_fields("", "", "", "", "", "", "", &db_conn).unwrap(); + let view = db_conn.search_txs(search).unwrap(); + + assert!(!view.is_empty()); + assert_eq!(view.get_tx(0).details, Some("Rent".into())); + assert_eq!(view.get_tx(1).details, Some("Salary".into())); + + let arr = view.tx_array(); + assert_eq!(arr.len(), 2); + // Each row has 6 columns: date, details, method, amount, tx_type, tags + assert_eq!(arr[0].len(), 6); + assert_eq!(arr[0][1], "Rent"); + assert_eq!(arr[0][4], "Expense"); + assert_eq!(arr[1][1], "Salary"); + assert_eq!(arr[1][4], "Income"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn search_view_no_results() { + let file_name = "test_search_view_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "Only tx", + "Cash", + "", + "10.00", + "Expense", + "Tag", + ); + + // Search for something that doesn't match + let search = parse_search_fields("", "", "", "", "", "Income", "", &db_conn).unwrap(); + let view = db_conn.search_txs(search).unwrap(); + + assert!(view.is_empty()); + assert_eq!(view.tx_array().len(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/stepper.rs b/app/tests/stepper.rs new file mode 100644 index 00000000..5501edc7 --- /dev/null +++ b/app/tests/stepper.rs @@ -0,0 +1,609 @@ +use rex_app::ui_helper::{DateType, StepType, SteppingError}; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +// ---- Date stepping ---- + +#[test] +fn step_date_exact_up() { + let file_name = "test_step_date.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06-15".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Exact) + .unwrap(); + assert_eq!(s, "2024-06-16"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_exact_down() { + let file_name = "test_step_date_down.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06-15".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepDown, DateType::Exact) + .unwrap(); + assert_eq!(s, "2024-06-14"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_monthly_up() { + let file_name = "test_step_date_monthly.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Monthly) + .unwrap(); + assert_eq!(s, "2024-07"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_monthly_down() { + let file_name = "test_step_date_monthly_down.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepDown, DateType::Monthly) + .unwrap(); + assert_eq!(s, "2024-05"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_yearly_up() { + let file_name = "test_step_date_yearly.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Yearly) + .unwrap(); + assert_eq!(s, "2025"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_yearly_down() { + let file_name = "test_step_date_yearly_down.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepDown, DateType::Yearly) + .unwrap(); + assert_eq!(s, "2023"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_from_empty_defaults() { + let file_name = "test_step_date_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + + let mut s = String::new(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Exact) + .unwrap(); + assert_eq!(s, "2022-01-01"); + + let mut s = String::new(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Monthly) + .unwrap(); + assert_eq!(s, "2022-01"); + + let mut s = String::new(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Yearly) + .unwrap(); + assert_eq!(s, "2022"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_date_month_boundary_wraps_year() { + let file_name = "test_step_date_month_wrap.sqlite"; + let mut db_conn = create_test_db(file_name); + + let mut s = "2024-12".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Monthly) + .unwrap(); + assert_eq!(s, "2025-01"); + + let mut s = "2024-01".to_string(); + db_conn + .step() + .date(&mut s, StepType::StepDown, DateType::Monthly) + .unwrap(); + assert_eq!(s, "2023-12"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Amount stepping ---- + +#[test] +fn step_amount_up() { + let file_name = "test_step_amount.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "5.00".to_string(); + db_conn.step().amount(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "6.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_amount_down() { + let file_name = "test_step_amount_down.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "5.00".to_string(); + db_conn.step().amount(&mut s, StepType::StepDown).unwrap(); + assert_eq!(s, "4.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_amount_down_hits_floor() { + let file_name = "test_step_amount_floor.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "0.00".to_string(); + db_conn.step().amount(&mut s, StepType::StepDown).unwrap(); + assert_eq!(s, "0.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_amount_from_empty_defaults() { + let file_name = "test_step_amount_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + db_conn.step().amount(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "0.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_amount_negative_restores_to_one() { + let file_name = "test_step_amount_negative.sqlite"; + let mut db_conn = create_test_db(file_name); + // After previous verification corrects negative to positive, stepping up + // from a state where VerifierError::AmountBelowZero would be returned + // should set the amount to 1.00. The easiest way to trigger this is + // to call with "0.00" which after verify returns AmountBelowZero. + // But 0.00 is caught by the stepping branch: if step_up && AmountBelowZero → 1.00 + // Actually '0.00' amount verify returns AmountBelowZero. + // Let's verify: stepping up on "0.00" → verify rejects → AmountBelowZero, StepUp → sets "1.00" + let mut s = "0.00".to_string(); + db_conn.step().amount(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "1.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tx Method stepping ---- + +#[test] +fn step_tx_method_up() { + let file_name = "test_step_method.sqlite"; + let mut db_conn = create_test_db(file_name); + // Methods: Cash(1), Bank(2), Other(3) + let mut s = "Cash".to_string(); + db_conn.step().tx_method(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Bank"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_method_wraps_at_end() { + let file_name = "test_step_method_wrap.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Other".to_string(); + db_conn.step().tx_method(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_method_down_wraps_at_start() { + let file_name = "test_step_method_wrap_down.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Cash".to_string(); + db_conn + .step() + .tx_method(&mut s, StepType::StepDown) + .unwrap(); + assert_eq!(s, "Other"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_method_from_empty() { + let file_name = "test_step_method_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + db_conn.step().tx_method(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tx Type stepping ---- + +#[test] +fn step_tx_type_up() { + let file_name = "test_step_type.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Income".to_string(); + db_conn.step().tx_type(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Expense"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_type_down_wraps() { + let file_name = "test_step_type_wrap.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Income".to_string(); + db_conn.step().tx_type(&mut s, StepType::StepDown).unwrap(); + // TxType order: Income, Expense, Transfer, Borrow, Lend, BorrowRepay, LendRepay + // Wrapping down from first goes to last + assert_eq!(s, "Lend Repay"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_type_cycles_all() { + let file_name = "test_step_type_cycle.sqlite"; + let mut db_conn = create_test_db(file_name); + let expected = [ + "Income", + "Expense", + "Transfer", + "Borrow", + "Lend", + "Borrow Repay", + "Lend Repay", + ]; + + let mut s = "Income".to_string(); + (1..expected.len()).for_each(|i| { + db_conn.step().tx_type(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, expected[i]); + }); + + // Wrap back to first + db_conn.step().tx_type(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Income"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_type_from_empty() { + let file_name = "test_step_type_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + db_conn.step().tx_type(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Income"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tag stepping ---- + +#[test] +fn step_tag_up() { + let file_name = "test_step_tag.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Add tags to the DB + add_tx( + &mut db_conn, + "2024-01-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Alpha", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "T2", + "Cash", + "", + "20.00", + "Expense", + "Beta", + ); + add_tx( + &mut db_conn, + "2024-01-01", + "T3", + "Cash", + "", + "30.00", + "Expense", + "Gamma", + ); + + let mut s = "Alpha".to_string(); + db_conn.step().tag(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Beta"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_wraps_at_end() { + let file_name = "test_step_tag_wrap.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-02-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Alpha", + ); + add_tx( + &mut db_conn, + "2024-02-01", + "T2", + "Cash", + "", + "20.00", + "Expense", + "Zulu", + ); + + let mut s = "Zulu".to_string(); + db_conn.step().tag(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Alpha"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_down_wraps() { + let file_name = "test_step_tag_wrap_down.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-03-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Alpha", + ); + add_tx( + &mut db_conn, + "2024-03-01", + "T2", + "Cash", + "", + "20.00", + "Expense", + "Beta", + ); + + // Tags include pre-seeded "Unknown": ["Alpha", "Beta", "Unknown"] + // Step down from Alpha (index 0) wraps to last + let mut s = "Alpha".to_string(); + db_conn.step().tag(&mut s, StepType::StepDown).unwrap(); + assert_eq!(s, "Unknown"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_from_empty() { + let file_name = "test_step_tag_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-04-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Alpha", + ); + + let mut s = String::new(); + db_conn.step().tag(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Alpha"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_fuzzy_corrects_then_steps() { + let file_name = "test_step_tag_fuzzy.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-05-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Groceries", + ); + add_tx( + &mut db_conn, + "2024-05-01", + "T2", + "Cash", + "", + "20.00", + "Expense", + "Rent", + ); + + // Invalid tag gets fuzzy-corrected, then stepped + let mut s = "Groce".to_string(); + let result = db_conn.step().tag(&mut s, StepType::StepUp); + assert!(result.is_err()); // Invalid tag error + assert_eq!(s, "Groceries"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_multiple_preserves_other_tags() { + let file_name = "test_step_tag_multi.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-06-01", + "T1", + "Cash", + "", + "10.00", + "Expense", + "Alpha", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "T2", + "Cash", + "", + "20.00", + "Expense", + "Beta", + ); + add_tx( + &mut db_conn, + "2024-06-01", + "T3", + "Cash", + "", + "30.00", + "Expense", + "Gamma", + ); + + // Only the last tag in a comma-separated list gets stepped + let mut s = "Alpha, Beta".to_string(); + db_conn.step().tag(&mut s, StepType::StepUp).unwrap(); + assert_eq!(s, "Alpha, Gamma"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Stepper error paths ---- + +#[test] +fn step_date_invalid_format_errors() { + let file_name = "test_step_date_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "not-a-date".to_string(); + let result = db_conn + .step() + .date(&mut s, StepType::StepUp, DateType::Exact); + assert!(result.is_err()); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_amount_non_numeric_errors() { + let file_name = "test_step_amount_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "abc".to_string(); + let result = db_conn.step().amount(&mut s, StepType::StepUp); + assert!(matches!(result, Err(SteppingError::InvalidAmount))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_method_unknown_errors() { + let file_name = "test_step_method_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "NonExistentMethod".to_string(); + let result = db_conn.step().tx_method(&mut s, StepType::StepUp); + assert!(matches!(result, Err(SteppingError::InvalidTxMethod))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tx_type_unknown_errors() { + let file_name = "test_step_type_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "UnknownType".to_string(); + let result = db_conn.step().tx_type(&mut s, StepType::StepUp); + assert!(matches!(result, Err(SteppingError::InvalidTxType))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn step_tag_empty_db_errors() { + let file_name = "test_step_tag_err.sqlite"; + let mut db_conn = create_test_db(file_name); + // No tags added — only pre-seeded "Unknown" exists, so empty+step finds it + // Actually "Unknown" is always there from migrations. Test with no user tags. + // Stepping from empty when no user tags exist still finds Unknown. + let mut s = "NonExistent".to_string(); + let result = db_conn.step().tag(&mut s, StepType::StepUp); + // Fuzzy-corrects to best match and errors + assert!(result.is_err()); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/summary.rs b/app/tests/summary.rs new file mode 100644 index 00000000..e58bf1e7 --- /dev/null +++ b/app/tests/summary.rs @@ -0,0 +1,644 @@ +use rex_app::conn::FetchNature; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn summary_monthly_basic_income_expense() { + let file_name = "test_summary_basic.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-06-01", + "Salary", + "Cash", + "", + "3000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-06-15", + "Rent", + "Cash", + "", + "1000.00", + "Expense", + "Housing", + ); + add_tx( + &mut db_conn, + "2024-06-20", + "Food", + "Cash", + "", + "500.00", + "Expense", + "Groceries", + ); + + let summary_view = db_conn + .get_summary_with_str("June", "2024", FetchNature::Monthly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + // Net: income=3000, expense=1500 + let net = full.net_array(); + assert_eq!(net[0][1], "3000.00"); + assert_eq!(net[0][2], "1500.00"); + + // Per-method breakdown: only Cash has data + let methods = full.method_array(); + let cash_row = methods.iter().find(|r| r[0] == "Cash").unwrap(); + assert_eq!(cash_row[1], "3000.00"); // earning + assert_eq!(cash_row[2], "1500.00"); // expense + + // Largest earning: Salary 3000 on 01-06-2024 + let largest = full.largest_array(); + assert_eq!(largest[0][0], "Largest Earning"); + assert_eq!(largest[0][2], "3000.00"); + assert_eq!(largest[1][0], "Largest Expense"); + assert_eq!(largest[1][2], "1000.00"); + + // Peak earning = 3000 (only one month with income) + let peak = full.peak_array(); + assert_eq!(peak[0][0], "Peak Earning"); + assert_eq!(peak[0][2], "3000.00"); + assert_eq!(peak[1][0], "Peak Expense"); + assert_eq!(peak[1][2], "1500.00"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_largest_single_tx_each() { + let file_name = "test_summary_largest.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "Small", + "Cash", + "", + "10.00", + "Income", + "A", + ); + add_tx( + &mut db_conn, + "2024-07-05", + "Big", + "Cash", + "", + "5000.00", + "Income", + "B", + ); + add_tx( + &mut db_conn, + "2024-07-10", + "Medium", + "Cash", + "", + "100.00", + "Expense", + "C", + ); + add_tx( + &mut db_conn, + "2024-07-15", + "Huge", + "Cash", + "", + "2000.00", + "Expense", + "D", + ); + + let summary_view = db_conn + .get_summary_with_str("July", "2024", FetchNature::Monthly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + let largest = full.largest_array(); + assert_eq!(largest[0][2], "5000.00"); // largest earning + assert_eq!(largest[1][2], "2000.00"); // largest expense + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_peak_across_months() { + let file_name = "test_summary_peak.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Month 1: low earning + add_tx( + &mut db_conn, + "2024-01-15", + "Jan", + "Cash", + "", + "100.00", + "Income", + "A", + ); + // Month 2: high earning + add_tx( + &mut db_conn, + "2024-02-15", + "Feb", + "Cash", + "", + "5000.00", + "Income", + "B", + ); + // Month 3: medium earning + add_tx( + &mut db_conn, + "2024-03-15", + "Mar", + "Cash", + "", + "1000.00", + "Income", + "C", + ); + + let summary_view = db_conn + .get_summary_with_str("January", "2024", FetchNature::Yearly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + let peak = full.peak_array(); + assert_eq!(peak[0][0], "Peak Earning"); + assert_eq!(peak[0][2], "5000.00"); + assert_eq!(peak[0][1], "02-2024"); // Feb date format + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_lend_borrows() { + let file_name = "test_summary_lend_borrow.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-08-01", + "Borrow1", + "Cash", + "", + "500.00", + "Borrow", + "Loan", + ); + add_tx( + &mut db_conn, + "2024-08-10", + "Lend1", + "Cash", + "", + "200.00", + "Lend", + "Friend", + ); + add_tx( + &mut db_conn, + "2024-08-15", + "Repay half", + "Cash", + "", + "250.00", + "Borrow Repay", + "Loan", + ); + add_tx( + &mut db_conn, + "2024-08-20", + "Got back", + "Cash", + "", + "100.00", + "Lend Repay", + "Friend", + ); + + let summary_view = db_conn + .get_summary_with_str("August", "2024", FetchNature::Monthly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + // Net borrows: 500 - 250 = 250 + // Net lends: 200 - 100 = 100 + let lb = full.lend_borrows_array(); + assert_eq!(lb[0][0], "250.00"); + assert_eq!(lb[0][1], "100.00"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_multiple_methods() { + let file_name = "test_summary_methods.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-09-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-09-05", + "Savings", + "Bank", + "", + "500.00", + "Income", + "Interest", + ); + add_tx( + &mut db_conn, + "2024-09-10", + "Coffee", + "Cash", + "", + "50.00", + "Expense", + "Food", + ); + + let summary_view = db_conn + .get_summary_with_str("September", "2024", FetchNature::Monthly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + let methods = full.method_array(); + let cash_row = methods.iter().find(|r| r[0] == "Cash").unwrap(); + let bank_row = methods.iter().find(|r| r[0] == "Bank").unwrap(); + let other_row = methods.iter().find(|r| r[0] == "Other").unwrap(); + + assert_eq!(cash_row[1], "1000.00"); // Cash earning + assert_eq!(cash_row[2], "50.00"); // Cash expense + assert_eq!(bank_row[1], "500.00"); // Bank earning + assert_eq!(bank_row[2], "0.00"); // Bank expense + assert_eq!(other_row[1], "0.00"); // Other: no activity + assert_eq!(other_row[2], "0.00"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_tags_array_groups_by_tag() { + let file_name = "test_summary_tags.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-10-01", + "Income", + "Cash", + "", + "2000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-10-10", + "Groceries", + "Cash", + "", + "200.00", + "Expense", + "Food", + ); + add_tx( + &mut db_conn, + "2024-10-20", + "Rent", + "Cash", + "", + "1000.00", + "Expense", + "Housing", + ); + + let summary_view = db_conn + .get_summary_with_str("October", "2024", FetchNature::Monthly) + .unwrap(); + let tags = summary_view.tags_array(None, &db_conn); + + // Find row for "Work" tag + let work_row = tags.iter().find(|r| r[0] == "Work").unwrap(); + assert_eq!(work_row[1], "2000.00"); // income amount + assert_eq!(work_row[2], "0.00"); // expense amount + + let food_row = tags.iter().find(|r| r[0] == "Food").unwrap(); + assert_eq!(food_row[1], "0.00"); + assert_eq!(food_row[2], "200.00"); // expense + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_yearly_with_monthly_averages() { + let file_name = "test_summary_yearly.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Jan: income 100 + add_tx( + &mut db_conn, + "2024-01-15", + "Jan", + "Cash", + "", + "100.00", + "Income", + "A", + ); + // Feb: income 300 + add_tx( + &mut db_conn, + "2024-02-15", + "Feb", + "Cash", + "", + "300.00", + "Income", + "B", + ); + + let summary_view = db_conn + .get_summary_with_str("January", "2024", FetchNature::Yearly) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + // Total income: 400, over 2 months, avg = 200 + let net = full.net_array(); + assert_eq!(net[0][1], "400.00"); // total income + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_tags_array_with_compare() { + let file_name = "test_summary_tags_compare.sqlite"; + let mut db_conn = create_test_db(file_name); + + // August: income + expense + add_tx( + &mut db_conn, + "2024-08-01", + "Salary", + "Cash", + "", + "2000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-08-15", + "Rent", + "Cash", + "", + "1000.00", + "Expense", + "Housing", + ); + + // September: different amounts + add_tx( + &mut db_conn, + "2024-09-01", + "Salary", + "Cash", + "", + "2500.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-09-15", + "Rent", + "Cash", + "", + "800.00", + "Expense", + "Housing", + ); + + let sep = db_conn + .get_summary_with_str("September", "2024", FetchNature::Monthly) + .unwrap(); + let aug = db_conn + .get_summary_with_str("August", "2024", FetchNature::Monthly) + .unwrap(); + + let tags = sep.tags_array(Some(&aug), &db_conn); + // Should include MoM/YoY comparison columns with ↑/↓ percentages + let work_row = tags.iter().find(|r| r[0] == "Work").unwrap(); + // Income amount for Work tag + assert_eq!(work_row[1], "2500.00"); + // Should have compare columns (positions 6-7 after income/expense %) + assert!(work_row.len() > 6, "Should have compare columns"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_with_last_summary_mom_comparison() { + let file_name = "test_summary_mom.sqlite"; + let mut db_conn = create_test_db(file_name); + + // August + add_tx( + &mut db_conn, + "2024-08-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-08-15", + "Expense", + "Cash", + "", + "200.00", + "Expense", + "Food", + ); + + // September — higher numbers + add_tx( + &mut db_conn, + "2024-09-01", + "Income", + "Cash", + "", + "2000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-09-15", + "Expense", + "Cash", + "", + "300.00", + "Expense", + "Food", + ); + + let aug_view = db_conn + .get_summary_with_str("August", "2024", FetchNature::Monthly) + .unwrap(); + let aug_full = aug_view.generate_summary(None, &db_conn); + + let sep_view = db_conn + .get_summary_with_str("September", "2024", FetchNature::Monthly) + .unwrap(); + let sep_full = sep_view.generate_summary(Some(&aug_full), &db_conn); + + // Net should have MoM comparison strings (↑/↓ with percentages) + let net = sep_full.net_array(); + assert!( + net[0].len() >= 7, + "Net array should have MoM columns, got len {}", + net[0].len() + ); + + // Methods should have MoM comparison + let methods = sep_full.method_array(); + let cash_row = methods.iter().find(|r| r[0] == "Cash").unwrap(); + assert!(cash_row.len() >= 7, "Method row should have MoM columns"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_lend_borrows_with_mom() { + let file_name = "test_summary_lb_mom.sqlite"; + let mut db_conn = create_test_db(file_name); + + // August: borrow 500 + add_tx( + &mut db_conn, + "2024-08-01", + "Borrow", + "Cash", + "", + "500.00", + "Borrow", + "Loan", + ); + + let aug_view = db_conn + .get_summary_with_str("August", "2024", FetchNature::Monthly) + .unwrap(); + let aug_full = aug_view.generate_summary(None, &db_conn); + + // September: borrow more, repay some + add_tx( + &mut db_conn, + "2024-09-01", + "Borrow more", + "Cash", + "", + "300.00", + "Borrow", + "Loan", + ); + add_tx( + &mut db_conn, + "2024-09-15", + "Repay", + "Cash", + "", + "200.00", + "Borrow Repay", + "Loan", + ); + + let sep_view = db_conn + .get_summary_with_str("September", "2024", FetchNature::Monthly) + .unwrap(); + let sep_full = sep_view.generate_summary(Some(&aug_full), &db_conn); + + // Lend borrows should have MoM columns + let lb = sep_full.lend_borrows_array(); + assert!( + lb[0].len() >= 2, + "Lend borrows array should have value columns" + ); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn summary_all_fetch_nature() { + let file_name = "test_summary_all.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-01-15", + "Jan income", + "Cash", + "", + "1000.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-06-20", + "Jun expense", + "Cash", + "", + "200.00", + "Expense", + "Food", + ); + + let summary_view = db_conn + .get_summary_with_str("January", "2024", FetchNature::All) + .unwrap(); + let full = summary_view.generate_summary(None, &db_conn); + + let net = full.net_array(); + assert_eq!(net[0][1], "1000.00"); // total income + assert_eq!(net[0][2], "200.00"); // total expense + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/tx_methods.rs b/app/tests/tx_methods.rs new file mode 100644 index 00000000..5b2420b2 --- /dev/null +++ b/app/tests/tx_methods.rs @@ -0,0 +1,159 @@ +use rex_db::ConnCache; +use std::fs; + +use crate::common::create_test_db; + +mod common; + +#[test] +fn add_new_method_increments_position() { + let file_name = "test_method_add.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Initial: Cash(1), Bank(2), Other(3) + let methods = db_conn.get_tx_methods_sorted(); + let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["Cash", "Bank", "Other"]); + assert_eq!(methods[0].position, 1); + assert_eq!(methods[1].position, 2); + assert_eq!(methods[2].position, 3); + + // Add one method + let new = vec!["Wallet".to_string()]; + db_conn.add_new_methods(&new).unwrap(); + + let methods = db_conn.get_tx_methods_sorted(); + let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["Cash", "Bank", "Other", "Wallet"]); + assert_eq!(methods[3].position, 4); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_multiple_methods_sequential_positions() { + let file_name = "test_method_add_multi.sqlite"; + let mut db_conn = create_test_db(file_name); + + let new = vec![ + "Wallet".to_string(), + "Savings".to_string(), + "Investments".to_string(), + ]; + db_conn.add_new_methods(&new).unwrap(); + + let methods = db_conn.get_tx_methods_sorted(); + let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!( + names, + vec!["Cash", "Bank", "Other", "Wallet", "Savings", "Investments"] + ); + assert_eq!(methods[3].position, 4); + assert_eq!(methods[4].position, 5); + assert_eq!(methods[5].position, 6); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn rename_method_updates_name_preserves_position() { + let file_name = "test_method_rename.sqlite"; + let mut db_conn = create_test_db(file_name); + + let pos_before = db_conn.get_tx_method_by_name("Cash").unwrap().position; + db_conn.rename_tx_method("Cash", "CashRenamed").unwrap(); + + let renamed = db_conn.get_tx_method_by_name("CashRenamed").unwrap(); + assert_eq!(renamed.name, "CashRenamed"); + assert_eq!(renamed.position, pos_before); + + // Old name no longer accessible via cache + assert!(db_conn.get_tx_method_by_name("Cash").is_err()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn reorder_methods_changes_positions() { + let file_name = "test_method_reorder.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Reverse order: Other, Bank, Cash + let new_order: Vec = vec!["Other", "Bank", "Cash"] + .into_iter() + .map(String::from) + .collect(); + + db_conn.set_new_tx_method_positions(&new_order).unwrap(); + + let methods = db_conn.get_tx_methods_sorted(); + let names: Vec<&str> = methods.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["Other", "Bank", "Cash"]); + // set_new_tx_method_positions assigns 0-based positions + assert_eq!(methods[0].position, 0); + assert_eq!(methods[1].position, 1); + assert_eq!(methods[2].position, 2); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn is_tx_method_empty() { + // create_test_db already adds 3 methods, so test with a fresh conn + let file_name = "test_method_empty.sqlite"; + let db_conn = create_test_db(file_name); + assert!(!db_conn.is_tx_method_empty()); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn get_tx_methods_cumulative_appends_cumulative() { + let file_name = "test_method_cumulative.sqlite"; + let db_conn = create_test_db(file_name); + + let cumulative = db_conn.get_tx_methods_cumulative(); + assert_eq!(cumulative, vec!["Cash", "Bank", "Other", "Cumulative"]); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn get_final_balances_returns_all_methods() { + let file_name = "test_method_final_balances.sqlite"; + let mut db_conn = create_test_db(file_name); + + let balances = db_conn.get_final_balances().unwrap(); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + let other_id = db_conn.cache().get_method_id("Other").unwrap(); + + assert!(balances.contains_key(&cash_id)); + assert!(balances.contains_key(&bank_id)); + assert!(balances.contains_key(&other_id)); + assert_eq!(balances.len(), 3); + + // Fresh methods should have zero final balance + assert_eq!(balances[&cash_id].balance, 0); + assert_eq!(balances[&bank_id].balance, 0); + assert_eq!(balances[&other_id].balance, 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn get_tx_method_by_name_not_found() { + let file_name = "test_method_not_found.sqlite"; + let mut db_conn = create_test_db(file_name); + + assert!(db_conn.get_tx_method_by_name("Nonexistent").is_err()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/tx_types.rs b/app/tests/tx_types.rs new file mode 100644 index 00000000..59b274ee --- /dev/null +++ b/app/tests/tx_types.rs @@ -0,0 +1,463 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_app::modifier::parse_tx_fields; +use rex_db::ConnCache; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +#[test] +fn transfer_moves_money_between_methods() { + let file_name = "test_transfer_basic.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + let other_id = db_conn.cache().get_method_id("Other").unwrap(); + + // Seed Cash with income + add_tx( + &mut db_conn, + "2024-06-01", + "Salary", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + // Transfer 300 from Cash to Bank + add_tx( + &mut db_conn, + "2024-06-10", + "Move funds", + "Cash", + "Bank", + "300.00", + "Transfer", + "Transfer", + ); + + let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 2); + + let balance = tx_view.get_tx_balance(1); + assert_eq!(balance[&cash_id].value(), 70000); // 1000 - 300 + assert_eq!(balance[&bank_id].value(), 30000); // 0 + 300 + assert_eq!(balance[&other_id].value(), 0); // unchanged + + // Total across all methods unchanged + let total: i64 = balance.values().map(|c| c.value()).sum(); + assert_eq!(total, 100000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn transfer_then_reverse_transfer_returns_to_original() { + let file_name = "test_transfer_reverse.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + add_tx( + &mut db_conn, + "2024-07-01", + "Salary", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + add_tx( + &mut db_conn, + "2024-07-05", + "To Bank", + "Cash", + "Bank", + "300.00", + "Transfer", + "Transfer", + ); + + add_tx( + &mut db_conn, + "2024-07-10", + "Back", + "Bank", + "Cash", + "300.00", + "Transfer", + "Transfer", + ); + + let date = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + let balance = tx_view.get_tx_balance(2); + assert_eq!(balance[&cash_id].value(), 100000); // 1000 - 300 + 300 + assert_eq!(balance[&bank_id].value(), 0); // 0 + 300 - 300 + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn delete_transfer_reverses_both_methods() { + let file_name = "test_transfer_delete.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + add_tx( + &mut db_conn, + "2024-08-01", + "Salary", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + let transfer = add_tx( + &mut db_conn, + "2024-08-10", + "Move", + "Cash", + "Bank", + "200.00", + "Transfer", + "Transfer", + ); + + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 30000); + assert_eq!(tx_view.get_tx_balance(1)[&bank_id].value(), 20000); + + db_conn.delete_tx(&transfer).unwrap(); + + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.len(), 1); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 50000); + assert_eq!(tx_view.get_tx_balance(0)[&bank_id].value(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn edit_transfer_amount_updates_both_methods() { + let file_name = "test_transfer_edit.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + add_tx( + &mut db_conn, + "2024-09-01", + "Salary", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + let transfer = add_tx( + &mut db_conn, + "2024-09-10", + "Move", + "Cash", + "Bank", + "100.00", + "Transfer", + "Transfer", + ); + + let new_tx = parse_tx_fields( + "2024-09-10", + "Move", + "Cash", + "Bank", + "500.00", + "Transfer", + &db_conn, + ) + .unwrap(); + db_conn.edit_tx(&transfer, new_tx, "Transfer").unwrap(); + + let date = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 50000); // 1000 - 500 + assert_eq!(tx_view.get_tx_balance(1)[&bank_id].value(), 50000); // 0 + 500 + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn borrow_increases_balance() { + let file_name = "test_borrow.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + add_tx( + &mut db_conn, + "2024-10-01", + "Borrow from bank", + "Cash", + "", + "500.00", + "Borrow", + "Loan", + ); + + let date = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(0)[&cash_id].value(), 50000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn borrow_repay_decreases_balance() { + let file_name = "test_borrow_repay.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + // Need money first + add_tx( + &mut db_conn, + "2024-11-01", + "Borrow", + "Cash", + "", + "500.00", + "Borrow", + "Loan", + ); + + add_tx( + &mut db_conn, + "2024-11-15", + "Repay loan", + "Cash", + "", + "500.00", + "Borrow Repay", + "Loan", + ); + + let date = NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + // Borrow +500, Repay -500 = 0 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn lend_decreases_balance() { + let file_name = "test_lend.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + add_tx( + &mut db_conn, + "2024-12-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + add_tx( + &mut db_conn, + "2024-12-10", + "Lend to friend", + "Cash", + "", + "200.00", + "Lend", + "Friend", + ); + + let date = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 80000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn lend_repay_increases_balance() { + let file_name = "test_lend_repay.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + + add_tx( + &mut db_conn, + "2025-01-01", + "Lend", + "Cash", + "", + "200.00", + "Lend", + "Friend", + ); + + add_tx( + &mut db_conn, + "2025-01-20", + "Friend repaid", + "Cash", + "", + "200.00", + "Lend Repay", + "Friend", + ); + + let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + // Lend -200, Repay +200 = 0 + assert_eq!(tx_view.get_tx_balance(1)[&cash_id].value(), 0); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn all_tx_types_impact_balance_correctly() { + let file_name = "test_all_tx_types.sqlite"; + let mut db_conn = create_test_db(file_name); + let cash_id = db_conn.cache().get_method_id("Cash").unwrap(); + let bank_id = db_conn.cache().get_method_id("Bank").unwrap(); + + // Income: +500 Cash + add_tx( + &mut db_conn, + "2025-02-01", + "Income", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + // Expense: -100 Cash + add_tx( + &mut db_conn, + "2025-02-05", + "Expense", + "Cash", + "", + "100.00", + "Expense", + "Food", + ); + + // Transfer: -200 from Cash, +200 to Bank + add_tx( + &mut db_conn, + "2025-02-10", + "Transfer", + "Cash", + "Bank", + "200.00", + "Transfer", + "Xfer", + ); + + // Borrow: +300 Cash + add_tx( + &mut db_conn, + "2025-02-15", + "Borrow", + "Cash", + "", + "300.00", + "Borrow", + "Loan", + ); + + // BorrowRepay: -150 Cash + add_tx( + &mut db_conn, + "2025-02-20", + "Borrow Repay", + "Cash", + "", + "150.00", + "Borrow Repay", + "Loan", + ); + + // Lend: -50 Cash + add_tx( + &mut db_conn, + "2025-02-25", + "Lend", + "Cash", + "", + "50.00", + "Lend", + "Friend", + ); + + // LendRepay: +25 Cash + add_tx( + &mut db_conn, + "2025-02-28", + "Lend Repay", + "Cash", + "", + "25.00", + "Lend Repay", + "Friend", + ); + + let date = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + let balance = tx_view.get_tx_balance(6); + + // Cash: 500 - 100 - 200 + 300 - 150 - 50 + 25 = 325 + assert_eq!(balance[&cash_id].value(), 32500); + // Bank: 0 + 200 = 200 + assert_eq!(balance[&bank_id].value(), 20000); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/tx_view.rs b/app/tests/tx_view.rs new file mode 100644 index 00000000..6280dbfd --- /dev/null +++ b/app/tests/tx_view.rs @@ -0,0 +1,321 @@ +use chrono::NaiveDate; +use rex_app::conn::FetchNature; +use rex_app::views::PartialTx; +use std::fs; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +fn get_col(rows: &[Vec], row: usize, col: usize) -> &str { + &rows[row][col] +} + +#[test] +fn balance_array_with_index_shows_running_balance() { + let file_name = "test_balance_array.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-06-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + add_tx( + &mut db_conn, + "2024-06-15", + "Expense", + "Cash", + "", + "200.00", + "Expense", + "Food", + ); + + let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // At index 0: running balance after first tx (Income +1000) + let arr = tx_view.balance_array(Some(0), &mut db_conn).unwrap(); + // Header row: ["", "Cash", "Bank", "Other", "Total"] + assert_eq!(get_col(&arr, 0, 1), "Cash"); + assert_eq!(get_col(&arr, 0, 4), "Total"); + // Balance row: ["Balance", "1000.00", "0.00", "0.00", "1000.00"] + assert_eq!(get_col(&arr, 1, 0), "Balance"); + assert_eq!(get_col(&arr, 1, 1), "1000.00"); // Cash + assert_eq!(get_col(&arr, 1, 4), "1000.00"); // Total + // Changes row for this tx: "↑1000.00" + assert_eq!(get_col(&arr, 2, 0), "Changes"); + assert!(get_col(&arr, 2, 1).contains("1000.00")); + // Income row: cumulative income up to this tx + assert_eq!(get_col(&arr, 3, 0), "Income"); + assert_eq!(get_col(&arr, 3, 1), "1000.00"); + // Expense row: cumulative expense up to this tx + assert_eq!(get_col(&arr, 4, 0), "Expense"); + assert_eq!(get_col(&arr, 4, 1), "0.00"); + + // At index 1: after Expense -200, running balance = 800 + let arr = tx_view.balance_array(Some(1), &mut db_conn).unwrap(); + assert_eq!(get_col(&arr, 1, 1), "800.00"); // Cash balance + assert_eq!(get_col(&arr, 1, 4), "800.00"); // Total + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_array_with_none_shows_final_balance() { + let file_name = "test_balance_array_none.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-07-01", + "Salary", + "Cash", + "", + "500.00", + "Income", + "Work", + ); + add_tx( + &mut db_conn, + "2024-07-01", + "Transfer", + "Cash", + "Bank", + "200.00", + "Transfer", + "Move", + ); + + let date = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // None → final balance (from Balance::get_final_balance) + let arr = tx_view.balance_array(None, &mut db_conn).unwrap(); + // Cash: 500 - 200 = 300, Bank: 200 + assert_eq!(get_col(&arr, 1, 1), "300.00"); // Cash + assert_eq!(get_col(&arr, 1, 2), "200.00"); // Bank + assert_eq!(get_col(&arr, 1, 4), "500.00"); // Total + + // Changes should all be 0.00 (no specific tx) + assert_eq!(get_col(&arr, 2, 1), "0.00"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn balance_array_daily_income_expense() { + let file_name = "test_balance_daily.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Same date: two incomes + add_tx( + &mut db_conn, + "2024-08-01", + "Morning", + "Cash", + "", + "100.00", + "Income", + "A", + ); + add_tx( + &mut db_conn, + "2024-08-01", + "Afternoon", + "Cash", + "", + "50.00", + "Income", + "B", + ); + add_tx( + &mut db_conn, + "2024-08-02", + "Next day", + "Cash", + "", + "200.00", + "Income", + "C", + ); + + let date = NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // At index 1 (second tx of Aug 1): daily income = 100+50 = 150 + let arr = tx_view.balance_array(Some(1), &mut db_conn).unwrap(); + assert_eq!(get_col(&arr, 5, 0), "Daily Income"); + assert_eq!(get_col(&arr, 5, 1), "150.00"); // 100+50 from same date + + // At index 2 (Aug 2): daily income = only the Aug 2 tx = 200 + let arr = tx_view.balance_array(Some(2), &mut db_conn).unwrap(); + assert_eq!(get_col(&arr, 5, 1), "200.00"); // only the Aug 2 tx + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_balance_array_projecting_new_tx() { + let file_name = "test_add_balance_array.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-09-01", + "Existing", + "Cash", + "", + "500.00", + "Income", + "Salary", + ); + + let date = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // Project what adding a new expense of $100 would look like at index 1 + let partial = Some(PartialTx { + from_method: "Cash", + to_method: "", + tx_type: "Expense", + amount: "100.00", + }); + let arr = tx_view + .add_tx_balance_array(Some(1), partial, &mut db_conn) + .unwrap(); + + // Balance row: existing 500 - projected 100 = 400 + assert_eq!(get_col(&arr, 1, 0), "Balance"); + assert_eq!(get_col(&arr, 1, 1), "400.00"); // Cash + + // Changes row: should show projected change + assert_eq!(get_col(&arr, 2, 0), "Changes"); + assert!(get_col(&arr, 2, 1).contains("100.00")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_balance_array_projection_transfer() { + let file_name = "test_add_balance_transfer.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-10-01", + "Income", + "Cash", + "", + "1000.00", + "Income", + "Salary", + ); + + let date = NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // Project adding a transfer of $300 from Cash to Bank + let partial = Some(PartialTx { + from_method: "Cash", + to_method: "Bank", + tx_type: "Transfer", + amount: "300.00", + }); + let arr = tx_view + .add_tx_balance_array(Some(1), partial, &mut db_conn) + .unwrap(); + + // Cash: 1000 - 300 = 700, Bank: 0 + 300 = 300 + assert_eq!(get_col(&arr, 1, 1), "700.00"); // Cash + assert_eq!(get_col(&arr, 1, 2), "300.00"); // Bank + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn get_tx_by_id_not_found() { + let file_name = "test_tx_by_id_miss.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-11-01", + "Only", + "Cash", + "", + "10.00", + "Expense", + "A", + ); + + let date = NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + assert!(tx_view.get_tx_by_id(999).is_none()); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn add_tx_balance_array_at_index_zero() { + let file_name = "test_add_balance_idx0.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2024-12-01", + "Existing", + "Cash", + "", + "500.00", + "Income", + "A", + ); + + let date = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + let tx_view = db_conn + .fetch_txs_with_date(date, FetchNature::Monthly) + .unwrap(); + + // At index 0, the balance shows what was BEFORE the first tx (reverses it) + let partial = Some(PartialTx { + from_method: "Cash", + to_method: "", + tx_type: "Expense", + amount: "100.00", + }); + let arr = tx_view + .add_tx_balance_array(Some(0), partial, &mut db_conn) + .unwrap(); + + // Before first tx: balance=0. Project expense -100 → -100 + assert_eq!(get_col(&arr, 1, 0), "Balance"); + assert_eq!(get_col(&arr, 1, 1), "-100.00"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/app/tests/verifier.rs b/app/tests/verifier.rs new file mode 100644 index 00000000..dcce4780 --- /dev/null +++ b/app/tests/verifier.rs @@ -0,0 +1,644 @@ +use rex_app::ui_helper::{DateType, Output, VerifierError}; +use std::fs; + +use crate::common::create_test_db; + +mod common; + +// ---- Date verification ---- + +#[test] +fn verify_date_exact_valid() { + let file_name = "test_verify_date.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06-15".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "2024-06-15"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_empty_returns_nothing() { + let file_name = "test_verify_date_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let result = db_conn.verify().date(&mut s, DateType::Exact).unwrap(); + assert!(matches!(result, Output::Nothing(_))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_year_too_short_is_corrected() { + let file_name = "test_verify_date_year_short.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "24-06-15".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::InvalidYear))); + assert_eq!(s, "2022-06-15"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_year_too_long_is_truncated() { + let file_name = "test_verify_date_year_long.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "20245-06-15".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::InvalidYear))); + assert_eq!(s, "2024-06-15"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_month_out_of_range_is_capped() { + let file_name = "test_verify_date_month_big.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-13-15".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::MonthTooBig))); + assert_eq!(s, "2024-12-15"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_day_out_of_range_is_capped() { + let file_name = "test_verify_date_day_big.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06-32".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::DayTooBig))); + assert_eq!(s, "2024-06-31"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_nonexistent_fails() { + let file_name = "test_verify_date_nonexistent.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-02-30".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::NonExistingDate))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_monthly_valid() { + let file_name = "test_verify_date_monthly.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Monthly).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "2024-06"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_yearly_valid() { + let file_name = "test_verify_date_yearly.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Yearly).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "2024"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_wrong_part_count_is_corrected() { + let file_name = "test_verify_date_wrong_parts.sqlite"; + let mut db_conn = create_test_db(file_name); + + // Exact expects 3 parts, monthly expects 2 + let mut s = "2024-06".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::InvalidDate))); + assert_eq!(s, "2022-01-01"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Amount verification ---- + +#[test] +fn verify_amount_valid() { + let file_name = "test_verify_amount.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "42.50".to_string(); + let v = db_conn.verify(); + let result = v.amount(&mut s).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "42.50"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_empty_returns_nothing() { + let file_name = "test_verify_amount_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let v = db_conn.verify(); + let result = v.amount(&mut s).unwrap(); + assert!(matches!(result, Output::Nothing(_))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_integer_gets_decimals() { + let file_name = "test_verify_amount_integer.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "10".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "10.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_one_decimal_gets_padded() { + let file_name = "test_verify_amount_one_dec.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "10.5".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "10.50"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_negative_becomes_positive_via_calc() { + let file_name = "test_verify_amount_negative.sqlite"; + let mut db_conn = create_test_db(file_name); + // '-' is a calc operator, so -5.00 goes through calc path: + // no left operand → resolves to just the right operand = 5.00 + let mut s = "-5.00".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "5.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_zero_is_rejected() { + let file_name = "test_verify_amount_zero.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "0".to_string(); + let v = db_conn.verify(); + let result = v.amount(&mut s); + assert!(matches!(result, Err(VerifierError::AmountBelowZero))); + assert_eq!(s, "0.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_calculation_multiplication() { + let file_name = "test_verify_amount_calc.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "1+2*3".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "7.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_calculation_division() { + let file_name = "test_verify_amount_calc_div.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "10/2".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "5.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_calculation_complex() { + let file_name = "test_verify_amount_calc_complex.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "10+5*2-3".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "17.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_non_numeric_chars_stripped() { + let file_name = "test_verify_amount_strip.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = " $ 1,234.56 ".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "1234.56"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tx Method verification ---- + +#[test] +fn verify_tx_method_exact_match() { + let file_name = "test_verify_method.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Cash".to_string(); + let v = db_conn.verify(); + let result = v.tx_method(&mut s).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_method_case_insensitive() { + let file_name = "test_verify_method_case.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "cash".to_string(); + let v = db_conn.verify(); + let result = v.tx_method(&mut s).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_method_empty() { + let file_name = "test_verify_method_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let v = db_conn.verify(); + let result = v.tx_method(&mut s).unwrap(); + assert!(matches!(result, Output::Nothing(_))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_method_fuzzy_correction() { + let file_name = "test_verify_method_fuzzy.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Csh".to_string(); + let v = db_conn.verify(); + let result = v.tx_method(&mut s); + assert!(matches!(result, Err(VerifierError::InvalidTxMethod))); + assert_eq!(s, "Cash"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tx Type verification ---- + +#[test] +fn verify_tx_type_empty() { + let file_name = "test_verify_type_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s).unwrap(); + assert!(matches!(result, Output::Nothing(_))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_type_shortcuts() { + let file_name = "test_verify_type_short.sqlite"; + let mut db_conn = create_test_db(file_name); + + let cases = [ + ("e", "Expense"), + ("E", "Expense"), + ("i", "Income"), + ("I", "Income"), + ("t", "Transfer"), + ("T", "Transfer"), + ("b", "Borrow"), + ("B", "Borrow"), + ("l", "Lend"), + ("L", "Lend"), + ("br", "Borrow Repay"), + ("BR", "Borrow Repay"), + ("lr", "Lend Repay"), + ("LR", "Lend Repay"), + ]; + + for (input, expected) in cases { + let mut s = input.to_string(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s).unwrap(); + assert!( + matches!(result, Output::Accepted(_)), + "Expected Accepted for input '{input}'" + ); + assert_eq!(s, expected, "Mismatch for input '{input}'"); + } + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_type_exact_match() { + let file_name = "test_verify_type_exact.sqlite"; + let mut db_conn = create_test_db(file_name); + + for expected in [ + "Income", + "Expense", + "Transfer", + "Borrow", + "Lend", + "Borrow Repay", + "Lend Repay", + ] { + let mut s = expected.to_string(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, expected); + } + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_type_fuzzy_correction() { + let file_name = "test_verify_type_fuzzy.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "Incom".to_string(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s); + assert!(matches!(result, Err(VerifierError::InvalidTxType))); + assert_eq!(s, "Income"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tags verification ---- + +#[test] +fn verify_tags_dedup_removes_duplicates() { + let file_name = "test_verify_tags_dedup.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "A, A, B".to_string(); + let v = db_conn.verify(); + v.tags(&mut s); + assert_eq!(s, "A, B"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tags_empty_is_unchanged() { + let file_name = "test_verify_tags_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let v = db_conn.verify(); + v.tags(&mut s); + assert_eq!(s, ""); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tags_trims_whitespace() { + let file_name = "test_verify_tags_trim.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = " Tag1 , Tag2 , Tag3 ".to_string(); + let v = db_conn.verify(); + v.tags(&mut s); + assert_eq!(s, "Tag1, Tag2, Tag3"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Tags forced verification ---- + +#[test] +fn verify_tags_forced_all_existing() { + let file_name = "test_verify_tags_forced.sqlite"; + let mut db_conn = create_test_db(file_name); + // Tags must exist in DB. Add one via a transaction. + use crate::common::add_tx; + add_tx( + &mut db_conn, + "2024-01-01", + "Test", + "Cash", + "", + "10.00", + "Expense", + "ExistingTag, AnotherTag", + ); + + let mut s = "ExistingTag, AnotherTag".to_string(); + let v = db_conn.verify(); + let result = v.tags_forced(&mut s).unwrap(); + assert!(matches!(result, Output::Accepted(_))); + assert_eq!(s, "ExistingTag, AnotherTag"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tags_forced_nonexistent_filtered() { + let file_name = "test_verify_tags_forced_filter.sqlite"; + let mut db_conn = create_test_db(file_name); + use crate::common::add_tx; + add_tx( + &mut db_conn, + "2024-02-01", + "Test", + "Cash", + "", + "10.00", + "Expense", + "RealTag", + ); + + let mut s = "RealTag, FakeTag".to_string(); + let v = db_conn.verify(); + let result = v.tags_forced(&mut s); + assert!(matches!(result, Err(VerifierError::NonExistingTag))); + assert_eq!(s, "RealTag"); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tags_forced_empty() { + let file_name = "test_verify_tags_forced_empty.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = String::new(); + let v = db_conn.verify(); + let result = v.tags_forced(&mut s).unwrap(); + assert!(matches!(result, Output::Nothing(_))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Verifier error paths ---- + +#[test] +fn verify_date_parsing_error() { + let file_name = "test_verify_date_parse_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "abc-def-ghi".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::ParsingError(_)))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_only_symbols_becomes_zero() { + let file_name = "test_verify_amount_parse_err.sqlite"; + let mut db_conn = create_test_db(file_name); + // "+" is a calc symbol; empty operands → result is "" → ".00" → 0.00 → AmountBelowZero + let mut s = "+".to_string(); + let v = db_conn.verify(); + let result = v.amount(&mut s); + assert!(matches!(result, Err(VerifierError::AmountBelowZero))); + assert_eq!(s, "0.00"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_method_not_found_fuzzy_corrects() { + let file_name = "test_verify_method_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "NonExistent".to_string(); + let v = db_conn.verify(); + let result = v.tx_method(&mut s); + assert!(matches!(result, Err(VerifierError::InvalidTxMethod))); + // Fuzzy corrects to closest match among Cash/Bank/Other + assert!(!s.is_empty()); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_type_long_invalid_fuzzy_corrects() { + let file_name = "test_verify_type_long_err.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "SomethingWeird".to_string(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s); + assert!(matches!(result, Err(VerifierError::InvalidTxType))); + // Gets fuzzy-corrected to closest match + assert!(!s.is_empty()); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_tx_type_short_invalid_fuzzy_corrects() { + let file_name = "test_verify_type_short_err.sqlite"; + let mut db_conn = create_test_db(file_name); + // Short (<2 chars) but not E/I/T/B/L/BR/LR + let mut s = "x".to_string(); + let v = db_conn.verify(); + let result = v.tx_type(&mut s); + assert!(matches!(result, Err(VerifierError::InvalidTxType))); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +// ---- Missing branches from coverage ---- + +#[test] +fn verify_date_monthly_wrong_parts() { + let file_name = "test_verify_date_monthly_parts.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Monthly); + assert!(matches!(result, Err(VerifierError::InvalidDate))); + assert_eq!(s, "2022-01"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_monthly_month_too_short() { + let file_name = "test_verify_date_monthly_short.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-6".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Monthly); + assert!(matches!(result, Err(VerifierError::InvalidMonth))); + assert_eq!(s, "2024-06"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_monthly_month_too_big() { + let file_name = "test_verify_date_monthly_big.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-13".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Monthly); + assert!(matches!(result, Err(VerifierError::MonthTooBig))); + assert_eq!(s, "2024-12"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_date_day_too_short() { + let file_name = "test_verify_date_day_short.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "2024-06-5".to_string(); + let result = db_conn.verify().date(&mut s, DateType::Exact); + assert!(matches!(result, Err(VerifierError::InvalidDay))); + assert_eq!(s, "2024-06-05"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_decimal_truncated() { + let file_name = "test_verify_amount_trunc.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "10.123".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "10.12"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn verify_amount_integer_limits_to_10_chars() { + let file_name = "test_verify_amount_limit.sqlite"; + let mut db_conn = create_test_db(file_name); + let mut s = "12345678901.50".to_string(); + let v = db_conn.verify(); + v.amount(&mut s).unwrap(); + assert_eq!(s, "1234567890.50"); + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} diff --git a/codecov.yml b/codecov.yml index e2072d08..e86bd8bb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,5 +2,5 @@ comment: false ignore: - "tui" - "app/tests" - - "db" - - "shared" + - "app/src/migration.rs" + - "app/src/ui_helper/output.rs" diff --git a/db/src/lib.rs b/db/src/lib.rs index a9e4afeb..2b5122e1 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -66,6 +66,10 @@ impl Cache { } } + pub fn new_details(&mut self, details: String) { + self.details.insert(details); + } + // TODO: Start using cache pub fn set_txs(&mut self, txs: HashMap>) { self.txs = Some(txs); diff --git a/db/src/models/balances.rs b/db/src/models/balances.rs index df28cc6c..47dc0f8a 100644 --- a/db/src/models/balances.rs +++ b/db/src/models/balances.rs @@ -278,12 +278,23 @@ impl Balance { pub fn get_balance_highest_date(db_conn: &mut impl ConnCache) -> Result, Error> { use crate::schema::balances::dsl::{balances, is_final_balance, month, year}; - let total_methods = db_conn.cache().tx_methods.len(); + let latest_date: Option<(i32, i32)> = balances + .filter(is_final_balance.eq(false)) + .select((year, month)) + .order((year.desc(), month.desc())) + .first::<(i32, i32)>(db_conn.conn()) + .optional()?; + + let (target_year, target_month) = match latest_date { + Some(date) => date, + None => return Ok(Vec::new()), + }; balances .filter(is_final_balance.eq(false)) - .order((year.desc(), month.desc())) - .limit(total_methods as i64) + .filter(year.eq(target_year)) + .filter(month.eq(target_month)) + .limit(db_conn.cache().tx_methods.len() as i64) .select(Self::as_select()) .load(db_conn.conn()) } diff --git a/shared/src/models.rs b/shared/src/models.rs index 80b8313f..13df799f 100644 --- a/shared/src/models.rs +++ b/shared/src/models.rs @@ -162,3 +162,166 @@ impl Dollar { Cent::new((self.0 * 100.0).round() as i64) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cent_new_and_value() { + let c = Cent::new(150); + assert_eq!(c.value(), 150); + } + + #[test] + fn cent_default_is_zero() { + assert_eq!(Cent::default(), Cent::new(0)); + } + + #[test] + fn cent_to_dollar() { + assert_eq!(Cent::new(150).dollar().value(), 1.50); + assert_eq!(Cent::new(0).dollar().value(), 0.0); + assert_eq!(Cent::new(-250).dollar().value(), -2.50); + } + + #[test] + fn dollar_to_cent() { + assert_eq!(Dollar::new(1.50).cent(), Cent::new(150)); + assert_eq!(Dollar::new(0.0).cent(), Cent::new(0)); + assert_eq!(Dollar::new(-2.50).cent(), Cent::new(-250)); + assert_eq!(Dollar::new(1.999).cent(), Cent::new(200)); + assert_eq!(Dollar::new(1.004).cent(), Cent::new(100)); + } + + #[test] + fn dollar_new_and_value() { + let d = Dollar::new(42.75); + assert_eq!(d.value(), 42.75); + } + + #[test] + fn dollar_display_formats_two_decimals() { + assert_eq!(format!("{}", Dollar::new(5.0)), "5.00"); + assert_eq!(format!("{}", Dollar::new(3.456)), "3.46"); + assert_eq!(format!("{}", Dollar::new(0.0)), "0.00"); + assert_eq!(format!("{}", Dollar::new(1234.5)), "1234.50"); + } + + #[test] + fn dollar_div() { + assert_eq!((Dollar::new(10.0) / 2.0).value(), 5.0); + assert_eq!((Dollar::new(3.0) / 2.0).value(), 1.5); + assert_eq!((Dollar::new(0.0) / 5.0).value(), 0.0); + } + + #[test] + fn cent_add_i64() { + assert_eq!(Cent::new(100) + 50, Cent::new(150)); + assert_eq!(Cent::new(-100) + 50, Cent::new(-50)); + } + + #[test] + fn cent_sub_i64() { + assert_eq!(Cent::new(100) - 50, Cent::new(50)); + assert_eq!(Cent::new(-100) - 50, Cent::new(-150)); + } + + #[test] + fn cent_mul_i64() { + assert_eq!(Cent::new(10) * 3, Cent::new(30)); + assert_eq!(Cent::new(-10) * 3, Cent::new(-30)); + assert_eq!(Cent::new(0) * 100, Cent::new(0)); + } + + #[test] + fn cent_add_assign_i64() { + let mut c = Cent::new(100); + c += 50; + assert_eq!(c, Cent::new(150)); + } + + #[test] + fn cent_sub_assign_i64() { + let mut c = Cent::new(100); + c -= 50; + assert_eq!(c, Cent::new(50)); + } + + #[test] + fn cent_add_assign_cent() { + let mut c = Cent::new(100); + c += Cent::new(50); + assert_eq!(c, Cent::new(150)); + } + + #[test] + fn cent_sub_assign_cent() { + let mut c = Cent::new(100); + c -= Cent::new(50); + assert_eq!(c, Cent::new(50)); + } + + #[test] + fn cent_eq_cent() { + assert_eq!(Cent::new(100), Cent::new(100)); + assert_ne!(Cent::new(100), Cent::new(200)); + } + + #[test] + fn cent_eq_i64() { + assert!(Cent::new(100) == 100); + assert!(Cent::new(100) != 200); + } + + #[test] + fn i64_eq_cent() { + assert!(100 == Cent::new(100)); + } + + #[test] + fn cent_partial_cmp() { + assert!(Cent::new(100) < Cent::new(200)); + assert!(Cent::new(200) > Cent::new(100)); + assert!(Cent::new(100) <= Cent::new(100)); + assert!(Cent::new(100) >= Cent::new(100)); + } + + #[test] + fn cent_partial_cmp_i64() { + assert!(Cent::new(100) < 200); + assert!(Cent::new(200) > 100); + assert!(Cent::new(100) <= 100); + } + + #[test] + fn i64_add_assign_cent() { + let mut v: i64 = 100; + v += Cent::new(50); + assert_eq!(v, 150); + } + + #[test] + fn i64_sub_assign_cent() { + let mut v: i64 = 100; + v -= Cent::new(50); + assert_eq!(v, 50); + } + + #[test] + fn cent_percent_change_normal() { + let diff = Cent::new(150).percent_change(Cent::new(100)).unwrap(); + assert!((diff - 50.0).abs() < 0.001); + } + + #[test] + fn cent_percent_change_decrease() { + let diff = Cent::new(50).percent_change(Cent::new(100)).unwrap(); + assert!((diff - (-50.0)).abs() < 0.001); + } + + #[test] + fn cent_percent_change_zero_previous() { + assert_eq!(Cent::new(100).percent_change(Cent::new(0)), None); + } +} diff --git a/tui/Cargo.toml b/tui/Cargo.toml index e9883889..217119f8 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -39,4 +39,4 @@ strum_macros.workspace = true thousands = "0.2.0" [lints.clippy] -too_many_arguments = "allow" +too_many_arguments = { level = "allow" } diff --git a/tui/src/key_checker/add_tx.rs b/tui/src/key_checker/add_tx.rs index c3dc9c8b..dcb1f1b9 100644 --- a/tui/src/key_checker/add_tx.rs +++ b/tui/src/key_checker/add_tx.rs @@ -24,10 +24,8 @@ pub fn add_tx_keys(handler: &mut InputKeyHandler) -> Result handler.go_activity(), KeyCode::Char('t') => handler.next_theme()?, KeyCode::Enter => handler.select_date_field(), - KeyCode::Char(c) => { - if c.is_numeric() { - handler.handle_number_press(); - } + KeyCode::Char(c) if c.is_numeric() => { + handler.handle_number_press(); } _ => {} }, diff --git a/tui/src/key_checker/key_handler.rs b/tui/src/key_checker/key_handler.rs index dd9c13b7..308dc35d 100644 --- a/tui/src/key_checker/key_handler.rs +++ b/tui/src/key_checker/key_handler.rs @@ -267,15 +267,12 @@ impl<'a> InputKeyHandler<'a> { /// Turns on deletion confirmation popup pub fn do_deletion_popup(&mut self) { match self.page { - CurrentUi::Home => { - if self.home_table.state.selected().is_some() { - *self.popup_status = PopupType::new_choice_deletion(self.theme); - } + CurrentUi::Home if self.home_table.state.selected().is_some() => { + *self.popup_status = PopupType::new_choice_deletion(self.theme); } - CurrentUi::Search => { - if self.search_table.state.selected().is_some() { - *self.popup_status = PopupType::new_choice_deletion(self.theme); - } + CurrentUi::Home => {} + CurrentUi::Search if self.search_table.state.selected().is_some() => { + *self.popup_status = PopupType::new_choice_deletion(self.theme); } _ => {} } @@ -2033,10 +2030,9 @@ impl InputKeyHandler<'_> { fn do_search_step(&mut self, step_type: StepType) { let status = match self.search_tab { - TxTab::Date => { - self.search_data - .step_date(*self.search_date_type, StepType::StepUp, self.conn) - } + TxTab::Date => self + .search_data + .step_date(*self.search_date_type, step_type, self.conn), TxTab::FromMethod => self.search_data.step_from_method(step_type, self.conn), TxTab::ToMethod => self.search_data.step_to_method(step_type, self.conn), TxTab::Amount => self.search_data.step_amount(false, step_type, self.conn), @@ -2170,13 +2166,11 @@ impl InputKeyHandler<'_> { Some(self.summary_years.titles[self.summary_years.index - 1].clone()); } } - 1 => { - if self.summary_years.index > 0 { - let year_value = self.summary_years.index; + 1 if self.summary_years.index > 0 => { + let year_value = self.summary_years.index; - previous_month = Some(self.summary_months.get_selected_value().to_string()); - previous_year = Some(self.summary_years.titles[year_value - 1].clone()); - } + previous_month = Some(self.summary_months.get_selected_value().to_string()); + previous_year = Some(self.summary_years.titles[year_value - 1].clone()); } _ => {} } diff --git a/tui/src/key_checker/search.rs b/tui/src/key_checker/search.rs index ee725bd9..08f3f92b 100644 --- a/tui/src/key_checker/search.rs +++ b/tui/src/key_checker/search.rs @@ -29,10 +29,8 @@ pub fn search_keys(handler: &mut InputKeyHandler) -> Result handler.handle_up_arrow(), KeyCode::Down => handler.handle_down_arrow(), KeyCode::Enter => handler.select_date_field(), - KeyCode::Char(c) => { - if c.is_numeric() { - handler.handle_number_press(); - } + KeyCode::Char(c) if c.is_numeric() => { + handler.handle_number_press(); } _ => {} }, diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index d8682982..7a01e3c3 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -578,7 +578,7 @@ impl PopupType { if let Some(modifying) = &input.modifying_method { conn.rename_tx_method(modifying, &input.text)?; } else { - conn.add_new_methods(&vec![input.text.clone()])?; + conn.add_new_methods(std::slice::from_ref(&input.text))?; } Ok(true)