@@ -1124,6 +1124,131 @@ transactions:
11241124 assert ! ( verify_ledger_balances( & ledger) . is_ok( ) ) ;
11251125 }
11261126
1127+ // ─── expand_account_hierarchy ──────────────────────────────────────────────
1128+
1129+ #[ test]
1130+ fn hierarchy_creates_parent_from_single_child ( ) {
1131+ let mut map = BalanceMap :: new ( ) ;
1132+ let mut child = CommodityMap :: new ( ) ;
1133+ commodity_map_add ( & mut child, make_amount ( 100 , 1 , "€" ) ) ;
1134+ map. insert ( "john:giro" . to_string ( ) , child) ;
1135+
1136+ let result = expand_account_hierarchy ( map) ;
1137+
1138+ assert ! ( result. contains_key( "john" ) , "Expected parent 'john'" ) ;
1139+ assert ! (
1140+ result. contains_key( "john:giro" ) ,
1141+ "Expected leaf 'john:giro'"
1142+ ) ;
1143+ assert_eq ! (
1144+ result[ "john" ] [ "€" ] . quantity, result[ "john:giro" ] [ "€" ] . quantity,
1145+ "Parent should equal child when there's only one child"
1146+ ) ;
1147+ }
1148+
1149+ #[ test]
1150+ fn hierarchy_aggregates_multiple_children ( ) {
1151+ let mut map = BalanceMap :: new ( ) ;
1152+
1153+ let mut savings = CommodityMap :: new ( ) ;
1154+ commodity_map_add ( & mut savings, make_amount ( 50 , 1 , "€" ) ) ;
1155+ map. insert ( "john:bank:savings" . to_string ( ) , savings) ;
1156+
1157+ let mut depot = CommodityMap :: new ( ) ;
1158+ commodity_map_add ( & mut depot, make_amount ( 30 , 1 , "€" ) ) ;
1159+ map. insert ( "john:bank:depot" . to_string ( ) , depot) ;
1160+
1161+ let mut visa = CommodityMap :: new ( ) ;
1162+ commodity_map_add ( & mut visa, make_amount ( 20 , 1 , "€" ) ) ;
1163+ map. insert ( "john:visa" . to_string ( ) , visa) ;
1164+
1165+ let result = expand_account_hierarchy ( map) ;
1166+
1167+ // john:bank = savings + depot = 80
1168+ assert_eq ! ( result[ "john:bank" ] [ "€" ] , make_amount( 80 , 1 , "€" ) ) ;
1169+ // john = visa + savings + depot = 100
1170+ assert_eq ! ( result[ "john" ] [ "€" ] , make_amount( 100 , 1 , "€" ) ) ;
1171+ // Leaves unchanged
1172+ assert_eq ! ( result[ "john:bank:savings" ] [ "€" ] , make_amount( 50 , 1 , "€" ) ) ;
1173+ assert_eq ! ( result[ "john:bank:depot" ] [ "€" ] , make_amount( 30 , 1 , "€" ) ) ;
1174+ assert_eq ! ( result[ "john:visa" ] [ "€" ] , make_amount( 20 , 1 , "€" ) ) ;
1175+ }
1176+
1177+ #[ test]
1178+ fn hierarchy_merges_default_suffix_into_parent ( ) {
1179+ // A single-segment account like "shop" becomes "shop:_default_"
1180+ // in the balance map. The hierarchy should normalize it to "shop"
1181+ // and not create a separate parent.
1182+ let mut map = BalanceMap :: new ( ) ;
1183+ let mut entry = CommodityMap :: new ( ) ;
1184+ commodity_map_add ( & mut entry, make_amount ( 10 , 1 , "€" ) ) ;
1185+ map. insert ( "shop:_default_" . to_string ( ) , entry) ;
1186+
1187+ let result = expand_account_hierarchy ( map) ;
1188+
1189+ assert ! ( result. contains_key( "shop" ) , "Expected normalized 'shop'" ) ;
1190+ assert ! (
1191+ !result. contains_key( "shop:_default_" ) ,
1192+ "_default_ key should be normalized away"
1193+ ) ;
1194+ assert_eq ! ( result[ "shop" ] [ "€" ] , make_amount( 10 , 1 , "€" ) ) ;
1195+ }
1196+
1197+ #[ test]
1198+ fn hierarchy_merges_default_with_explicit_children ( ) {
1199+ // Direct transfers to "john" (stored as john:_default_) plus
1200+ // transfers to john:giro should both roll up into "john".
1201+ let mut map = BalanceMap :: new ( ) ;
1202+
1203+ let mut direct = CommodityMap :: new ( ) ;
1204+ commodity_map_add ( & mut direct, make_amount ( 40 , 1 , "€" ) ) ;
1205+ map. insert ( "john:_default_" . to_string ( ) , direct) ;
1206+
1207+ let mut giro = CommodityMap :: new ( ) ;
1208+ commodity_map_add ( & mut giro, make_amount ( 60 , 1 , "€" ) ) ;
1209+ map. insert ( "john:giro" . to_string ( ) , giro) ;
1210+
1211+ let result = expand_account_hierarchy ( map) ;
1212+
1213+ // john = direct (40) + giro (60) = 100
1214+ assert_eq ! ( result[ "john" ] [ "€" ] , make_amount( 100 , 1 , "€" ) ) ;
1215+ // john:giro is unchanged
1216+ assert_eq ! ( result[ "john:giro" ] [ "€" ] , make_amount( 60 , 1 , "€" ) ) ;
1217+ }
1218+
1219+ #[ test]
1220+ fn hierarchy_handles_multiple_commodities ( ) {
1221+ let mut map = BalanceMap :: new ( ) ;
1222+
1223+ let mut giro = CommodityMap :: new ( ) ;
1224+ commodity_map_add ( & mut giro, make_amount ( 50 , 1 , "€" ) ) ;
1225+ commodity_map_add ( & mut giro, make_amount ( 3 , 1 , "BTC" ) ) ;
1226+ map. insert ( "john:giro" . to_string ( ) , giro) ;
1227+
1228+ let mut wallet = CommodityMap :: new ( ) ;
1229+ commodity_map_add ( & mut wallet, make_amount ( 20 , 1 , "€" ) ) ;
1230+ map. insert ( "john:wallet" . to_string ( ) , wallet) ;
1231+
1232+ let result = expand_account_hierarchy ( map) ;
1233+
1234+ assert_eq ! ( result[ "john" ] [ "€" ] , make_amount( 70 , 1 , "€" ) ) ;
1235+ assert_eq ! ( result[ "john" ] [ "BTC" ] , make_amount( 3 , 1 , "BTC" ) ) ;
1236+ }
1237+
1238+ #[ test]
1239+ fn hierarchy_no_parent_for_top_level_account ( ) {
1240+ // A top-level account should not create any parents.
1241+ let mut map = BalanceMap :: new ( ) ;
1242+ let mut entry = CommodityMap :: new ( ) ;
1243+ commodity_map_add ( & mut entry, make_amount ( 10 , 1 , "€" ) ) ;
1244+ map. insert ( "shop" . to_string ( ) , entry) ;
1245+
1246+ let result = expand_account_hierarchy ( map) ;
1247+
1248+ assert_eq ! ( result. len( ) , 1 ) ;
1249+ assert ! ( result. contains_key( "shop" ) ) ;
1250+ }
1251+
11271252 // ─── show_balance ─────────────────────────────────────────────────────────
11281253
11291254 fn simple_ledger ( ) -> Ledger {
0 commit comments