Lines
99.72 %
Functions
100 %
Branches
use super::*;
use chrono::NaiveDate;
use finance::error::ReportError;
use num_rational::Rational64;
use super::super::FilterEntity;
use super::filter::SqlParam;
use super::tree::{AccountAmounts, AccountRow, ConversionTarget, accumulate_split_converted};
use super::period::{
generate_month_boundaries, generate_quarter_boundaries, generate_year_boundaries,
};
use super::tree::build_tree;
#[test]
fn test_to_sql_account_eq() {
let id = Uuid::new_v4();
let filter = ReportFilter::AccountEq(id);
let mut offset = 0;
let (sql, params) = filter.to_sql(&mut offset);
assert_eq!(sql, "s.account_id = $1");
assert_eq!(offset, 1);
assert!(matches!(¶ms[0], SqlParam::Uuid(v) if *v == id));
}
fn test_to_sql_and_combinator() {
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let filter = ReportFilter::And(vec![
ReportFilter::AccountEq(id1),
ReportFilter::CommodityEq(id2),
]);
let mut offset = 2;
assert_eq!(sql, "(s.account_id = $3 AND s.commodity_id = $4)");
assert_eq!(params.len(), 2);
assert_eq!(offset, 4);
fn test_to_sql_not() {
let filter = ReportFilter::Not(Box::new(ReportFilter::AccountEq(id)));
let (sql, _params) = filter.to_sql(&mut offset);
assert_eq!(sql, "NOT (s.account_id = $1)");
fn test_to_sql_amount_gt() {
let r = Rational64::new(100, 1);
let filter = ReportFilter::AmountGt(r);
assert_eq!(sql, "s.value_num * $2 > $1 * s.value_denom");
assert!(matches!(¶ms[0], SqlParam::I64(100)));
assert!(matches!(¶ms[1], SqlParam::I64(1)));
fn test_to_sql_counterparty_eq() {
let filter = ReportFilter::CounterpartyEq(id);
let (sql, _) = filter.to_sql(&mut offset);
assert!(sql.contains("EXISTS"));
assert!(sql.contains("o.account_id = $1"));
fn test_to_sql_tag_filter() {
let filter = ReportFilter::Tag {
entity: FilterEntity::Account,
name: "category".to_string(),
value: "food".to_string(),
assert!(sql.contains("account_tags"));
assert!(sql.contains("$1"));
assert!(sql.contains("$2"));
fn test_to_sql_or_combinator() {
let filter = ReportFilter::Or(vec![
ReportFilter::AccountEq(Uuid::new_v4()),
assert!(sql.contains(" OR "));
fn test_to_sql_account_subtree() {
let filter = ReportFilter::AccountSubtree(id);
assert!(sql.contains("WITH RECURSIVE descendants"));
fn test_to_sql_account_in() {
let ids = vec![Uuid::new_v4(), Uuid::new_v4()];
let filter = ReportFilter::AccountIn(ids.clone());
assert_eq!(sql, "s.account_id = ANY($1)");
assert!(matches!(¶ms[0], SqlParam::UuidVec(v) if v.len() == 2));
fn test_build_tree_empty() {
let accounts: Vec<AccountRow> = vec![];
let amounts = AccountAmounts::new();
let roots = build_tree(&accounts, &amounts);
assert!(roots.is_empty());
fn test_build_tree_single_account() {
let commodity_id = Uuid::new_v4();
let accounts = vec![AccountRow {
account_id: id,
parent_id: None,
account_name: "Assets".to_string(),
}];
let mut amounts = AccountAmounts::new();
amounts.insert(
id,
[(commodity_id, (Rational64::new(100, 1), "USD".to_string()))]
.into_iter()
.collect(),
);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].account_name, "Assets");
assert_eq!(roots[0].account_path, "Assets");
assert_eq!(roots[0].depth, 0);
assert_eq!(roots[0].amounts.len(), 1);
assert_eq!(roots[0].amounts[0].amount, Rational64::new(100, 1));
fn test_build_tree_hierarchy() {
let parent_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
let accounts = vec![
AccountRow {
account_id: parent_id,
},
account_id: child_id,
parent_id: Some(parent_id),
account_name: "Bank".to_string(),
];
child_id,
[(commodity_id, (Rational64::new(50, 1), "USD".to_string()))]
assert_eq!(roots[0].children.len(), 1);
assert_eq!(roots[0].children[0].account_path, "Assets:Bank");
assert_eq!(roots[0].children[0].depth, 1);
assert_eq!(roots[0].amounts[0].amount, Rational64::new(50, 1));
assert_eq!(
roots[0].children[0].amounts[0].amount,
Rational64::new(50, 1)
fn test_build_tree_prunes_empty_accounts() {
let child_with_data = Uuid::new_v4();
let child_empty = Uuid::new_v4();
let empty_root = Uuid::new_v4();
account_id: child_with_data,
account_id: child_empty,
account_name: "Empty".to_string(),
account_id: empty_root,
account_name: "Liabilities".to_string(),
child_with_data,
assert_eq!(roots.len(), 1, "empty root Liabilities should be pruned");
roots[0].children.len(),
1,
"empty child Empty should be pruned"
assert_eq!(roots[0].children[0].account_name, "Bank");
fn test_generate_month_boundaries() {
let from = NaiveDate::from_ymd_opt(2025, 1, 15)
.unwrap()
.and_hms_opt(0, 0, 0)
.and_utc();
let to = NaiveDate::from_ymd_opt(2025, 4, 1)
let boundaries = generate_month_boundaries(from, to);
assert_eq!(boundaries.len(), 3);
assert_eq!(boundaries[0].0, "2025-01");
assert_eq!(boundaries[1].0, "2025-02");
assert_eq!(boundaries[2].0, "2025-03");
fn test_generate_quarter_boundaries() {
let from = NaiveDate::from_ymd_opt(2025, 1, 1)
let to = NaiveDate::from_ymd_opt(2025, 7, 1)
let boundaries = generate_quarter_boundaries(from, to);
assert_eq!(boundaries.len(), 2);
assert_eq!(boundaries[0].0, "2025-Q1");
assert_eq!(boundaries[1].0, "2025-Q2");
fn test_generate_year_boundaries() {
let from = NaiveDate::from_ymd_opt(2024, 6, 1)
let to = NaiveDate::from_ymd_opt(2026, 3, 1)
let boundaries = generate_year_boundaries(from, to);
assert_eq!(boundaries[0].0, "2024");
assert_eq!(boundaries[1].0, "2025");
assert_eq!(boundaries[2].0, "2026");
fn test_accumulate_split_converted_same_commodity() {
let account_id = Uuid::new_v4();
let target = ConversionTarget {
commodity_id,
symbol: "USD",
let result = accumulate_split_converted(
&mut amounts,
account_id,
Rational64::new(100, 1),
"USD",
&target,
(None, None),
assert!(result.is_ok());
amounts[&account_id][&commodity_id].0,
Rational64::new(100, 1)
fn test_accumulate_split_converted_with_price() {
let from_commodity = Uuid::new_v4();
let target_commodity = Uuid::new_v4();
commodity_id: target_commodity,
from_commodity,
"EUR",
(Some(1176), Some(1000)),
amounts[&account_id][&target_commodity].0,
Rational64::new(100, 1) * Rational64::new(1176, 1000)
fn test_accumulate_split_converted_missing_price() {
commodity_id: Uuid::new_v4(),
Uuid::new_v4(),
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ReportError::MissingConversion { .. }));
#[cfg(feature = "scripting")]
mod sexpr_tests {
fn test_from_sexpr_account_eq() {
let input = format!("(account= \"{id}\")");
let filter = ReportFilter::from_sexpr(&input).unwrap();
assert!(matches!(filter, ReportFilter::AccountEq(v) if v == id));
fn test_from_sexpr_commodity_eq() {
let input = format!("(commodity= \"{id}\")");
assert!(matches!(filter, ReportFilter::CommodityEq(v) if v == id));
fn test_from_sexpr_amount_gt() {
let input = "(amount> 100)";
let filter = ReportFilter::from_sexpr(input).unwrap();
assert!(matches!(filter, ReportFilter::AmountGt(r) if r == Rational64::new(100, 1)));
fn test_from_sexpr_and() {
let input = format!("(and (account= \"{id1}\") (commodity= \"{id2}\"))");
assert!(matches!(filter, ReportFilter::And(v) if v.len() == 2));
fn test_from_sexpr_not() {
let input = format!("(not (account= \"{id}\"))");
assert!(matches!(filter, ReportFilter::Not(_)));
fn test_from_sexpr_tag() {
let input = "(account-tag= \"category\" \"food\")";
match filter {
ReportFilter::Tag {
entity,
name,
value,
} => {
assert!(matches!(entity, FilterEntity::Account));
assert_eq!(name, "category");
assert_eq!(value, "food");
_ => panic!("Expected Tag filter"),
fn test_from_sexpr_account_subtree() {
let input = format!("(account-subtree \"{id}\")");
assert!(matches!(filter, ReportFilter::AccountSubtree(v) if v == id));
fn test_from_sexpr_invalid() {
let result = ReportFilter::from_sexpr("invalid");
fn test_from_sexpr_counterparty() {
let input = format!("(counterparty= \"{id}\")");
assert!(matches!(filter, ReportFilter::CounterpartyEq(v) if v == id));
fn test_from_sexpr_or() {
let input = format!("(or (account= \"{id1}\") (account= \"{id2}\"))");
assert!(matches!(filter, ReportFilter::Or(v) if v.len() == 2));