1
//! Tests for transaction-edit reverse-lowering.
2

            
3
use num_rational::Rational64;
4

            
5
use super::{parse_amount, parse_editable_transaction};
6

            
7
const TX: &str = "tx-00000000-0000-0000-0000-000000000001";
8
const DATE: &str = "2024-01-15T00:00:00+00:00";
9
const A1: &str = "a1000000-0000-0000-0000-000000000001";
10
const A2: &str = "a2000000-0000-0000-0000-000000000002";
11
const A3: &str = "a3000000-0000-0000-0000-000000000003";
12
const A4: &str = "a4000000-0000-0000-0000-000000000004";
13
const C1: &str = "c1000000-0000-0000-0000-000000000001";
14
const C2: &str = "c2000000-0000-0000-0000-000000000002";
15
const S1: &str = "s1000000-0000-0000-0000-000000000001";
16
const S2: &str = "s2000000-0000-0000-0000-000000000002";
17
const S3: &str = "s3000000-0000-0000-0000-000000000003";
18
const S4: &str = "s4000000-0000-0000-0000-000000000004";
19

            
20
9
fn wire(plist: &str) -> String {
21
9
    format!(r#"(:id 1 :value "{}")"#, plist.replace('"', "\\\""))
22
9
}
23

            
24
9
fn tx_plist(splits: &str, prices: &str, note: Option<&str>) -> String {
25
9
    let note_part = note
26
9
        .map(|n| format!(r#" :note "{}""#, n))
27
9
        .unwrap_or_default();
28
9
    format!(
29
        r#"(:id "{TX}" :post-date "{DATE}" :enter-date "{DATE}"{note_part} :splits ({splits}) :prices ({prices}))"#
30
    )
31
9
}
32

            
33
#[test]
34
1
fn single_currency_two_splits_produces_one_row() {
35
1
    let splits = format!(
36
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value -100)(:id "{S2}" :account-id "{A2}" :commodity-id "{C1}" :value 100)"#
37
    );
38
1
    let plist = tx_plist(&splits, "", None);
39
1
    let w = wire(&plist);
40

            
41
1
    let tx = parse_editable_transaction(&w).expect("must parse");
42
1
    assert_eq!(tx.id, TX);
43
1
    assert_eq!(tx.date, DATE);
44
1
    assert_eq!(tx.note, "");
45
1
    assert_eq!(tx.rows.len(), 1);
46

            
47
1
    let row = &tx.rows[0];
48
1
    assert_eq!(row.from_account, A1);
49
1
    assert_eq!(row.to_account, A2);
50
1
    assert_eq!(row.from_commodity, C1);
51
1
    assert_eq!(row.to_commodity, C1);
52
1
    assert_eq!(row.value, "100");
53
1
    assert!(row.to_amount.is_none());
54

            
55
1
    let parsed = parse_amount(&row.value).expect("value must round-trip");
56
1
    assert_eq!(parsed, Rational64::new(100, 1));
57
1
}
58

            
59
#[test]
60
1
fn cross_currency_one_price_produces_one_row_with_to_amount() {
61
1
    let splits = format!(
62
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value -15381/100)(:id "{S2}" :account-id "{A2}" :commodity-id "{C2}" :value 15000)"#
63
    );
64
1
    let prices = format!(
65
        r#"(:id "p1" :commodity-id "{C2}" :currency-id "{C1}" :commodity-split "{S2}" :currency-split "{S1}" :value-num 15381 :value-denom 1500000 :date "{DATE}")"#
66
    );
67
1
    let plist = tx_plist(&splits, &prices, Some("FX transfer"));
68
1
    let w = wire(&plist);
69

            
70
1
    let tx = parse_editable_transaction(&w).expect("must parse");
71
1
    assert_eq!(tx.note, "FX transfer");
72
1
    assert_eq!(tx.rows.len(), 1);
73

            
74
1
    let row = &tx.rows[0];
75
1
    assert_eq!(row.from_account, A1, "currency_split → from account");
76
1
    assert_eq!(row.to_account, A2, "commodity_split → to account");
77
1
    assert_eq!(row.from_commodity, C1, "currency side commodity");
78
1
    assert_eq!(row.to_commodity, C2, "commodity side commodity");
79
1
    assert_eq!(row.value, "15381/100");
80
1
    assert_eq!(row.to_amount.as_deref(), Some("15000"));
81

            
82
1
    let v = parse_amount(&row.value).expect("value round-trip");
83
1
    assert_eq!(v, Rational64::new(15381, 100));
84
1
    let ta = parse_amount(row.to_amount.as_deref().unwrap()).expect("to_amount round-trip");
85
1
    assert_eq!(ta, Rational64::new(15000, 1));
86
1
}
87

            
88
#[test]
89
1
fn four_splits_two_same_currency_rows() {
90
    // Distinct magnitudes (100 and 50) so magnitude pairing is unambiguous.
91
1
    let splits = format!(
92
        "(:id \"{S1}\" :account-id \"{A1}\" :commodity-id \"{C1}\" :value -100)\
93
         (:id \"{S2}\" :account-id \"{A2}\" :commodity-id \"{C1}\" :value 100)\
94
         (:id \"{S3}\" :account-id \"{A3}\" :commodity-id \"{C1}\" :value -50)\
95
         (:id \"{S4}\" :account-id \"{A4}\" :commodity-id \"{C1}\" :value 50)"
96
    );
97
1
    let plist = tx_plist(&splits, "", None);
98
1
    let w = wire(&plist);
99

            
100
1
    let tx = parse_editable_transaction(&w).expect("must parse 4-split tx");
101
1
    assert_eq!(tx.rows.len(), 2);
102

            
103
    // Rows are sorted by from_account: A1 (magnitude 100), then A3 (magnitude 50).
104
1
    assert_eq!(tx.rows[0].from_account, A1);
105
1
    assert_eq!(tx.rows[0].to_account, A2);
106
1
    assert_eq!(tx.rows[0].value, "100");
107
1
    assert_eq!(tx.rows[1].from_account, A3);
108
1
    assert_eq!(tx.rows[1].to_account, A4);
109
1
    assert_eq!(tx.rows[1].value, "50");
110
2
    for row in &tx.rows {
111
2
        assert!(row.to_amount.is_none(), "same-commodity has no to_amount");
112
    }
113
1
}
114

            
115
#[test]
116
1
fn two_equal_magnitude_same_commodity_pairs_is_ambiguous_err() {
117
    // Two rows of identical magnitude in one commodity cannot be matched to
118
    // their original accounts without guessing — must Err, never corrupt.
119
1
    let splits = format!(
120
        "(:id \"{S1}\" :account-id \"{A1}\" :commodity-id \"{C1}\" :value -100)\
121
         (:id \"{S2}\" :account-id \"{A2}\" :commodity-id \"{C1}\" :value 100)\
122
         (:id \"{S3}\" :account-id \"{A3}\" :commodity-id \"{C1}\" :value -100)\
123
         (:id \"{S4}\" :account-id \"{A4}\" :commodity-id \"{C1}\" :value 100)"
124
    );
125
1
    let plist = tx_plist(&splits, "", None);
126
1
    let w = wire(&plist);
127

            
128
1
    let err = parse_editable_transaction(&w).expect_err("equal magnitudes must be ambiguous");
129
1
    assert!(err.contains("ambiguous"), "should report ambiguity: {err}");
130
1
}
131

            
132
#[test]
133
1
fn unmatched_split_count_is_err() {
134
1
    let splits = format!(
135
        "(:id \"{S1}\" :account-id \"{A1}\" :commodity-id \"{C1}\" :value -100)\
136
         (:id \"{S2}\" :account-id \"{A2}\" :commodity-id \"{C1}\" :value 100)\
137
         (:id \"{S3}\" :account-id \"{A3}\" :commodity-id \"{C1}\" :value -50)"
138
    );
139
1
    let plist = tx_plist(&splits, "", None);
140
1
    let w = wire(&plist);
141

            
142
1
    let err = parse_editable_transaction(&w).expect_err("odd count must fail");
143
1
    assert!(
144
1
        err.contains("unpaired"),
145
        "should mention unpaired split: {err}"
146
    );
147
1
}
148

            
149
#[test]
150
1
fn cross_currency_wrong_sign_from_split_is_err() {
151
    // FROM split is positive (stale/malformed) — must Err, not silently flip.
152
1
    let splits = format!(
153
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value 15381/100)(:id "{S2}" :account-id "{A2}" :commodity-id "{C2}" :value 15000)"#
154
    );
155
1
    let prices = format!(
156
        r#"(:id "p1" :commodity-id "{C2}" :currency-id "{C1}" :commodity-split "{S2}" :currency-split "{S1}" :value-num 15381 :value-denom 1500000 :date "{DATE}")"#
157
    );
158
1
    let plist = tx_plist(&splits, &prices, None);
159
1
    let w = wire(&plist);
160

            
161
1
    let err = parse_editable_transaction(&w).expect_err("wrong-signed FROM must fail");
162
1
    assert!(err.contains("negative value"), "should reject sign: {err}");
163
1
}
164

            
165
#[test]
166
1
fn split_referenced_by_two_prices_is_err() {
167
1
    let splits = format!(
168
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value -100)(:id "{S2}" :account-id "{A2}" :commodity-id "{C2}" :value 100)"#
169
    );
170
1
    let prices = format!(
171
        r#"(:id "p1" :commodity-id "{C2}" :currency-id "{C1}" :commodity-split "{S2}" :currency-split "{S1}" :value-num 1 :value-denom 1 :date "{DATE}")(:id "p2" :commodity-id "{C2}" :currency-id "{C1}" :commodity-split "{S2}" :currency-split "{S1}" :value-num 1 :value-denom 1 :date "{DATE}")"#
172
    );
173
1
    let plist = tx_plist(&splits, &prices, None);
174
1
    let w = wire(&plist);
175

            
176
1
    let err = parse_editable_transaction(&w).expect_err("double-consume must fail");
177
1
    assert!(
178
1
        err.contains("multiple prices"),
179
        "should report double-consume: {err}"
180
    );
181
1
}
182

            
183
#[test]
184
1
fn i64_min_value_is_err_not_panic() {
185
    // i64::MIN cannot be negated to a magnitude; must surface as Err.
186
1
    let splits = format!(
187
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value -9223372036854775808)(:id "{S2}" :account-id "{A2}" :commodity-id "{C1}" :value 100)"#
188
    );
189
1
    let plist = tx_plist(&splits, "", None);
190
1
    let w = wire(&plist);
191

            
192
1
    let err = parse_editable_transaction(&w).expect_err("i64::MIN must fail cleanly");
193
1
    assert!(!err.is_empty());
194
1
}
195

            
196
#[test]
197
1
fn garbage_wire_is_err() {
198
1
    let err = parse_editable_transaction("not a wire frame").expect_err("garbage must fail");
199
1
    assert!(!err.is_empty());
200
1
    let err2 = parse_editable_transaction("").expect_err("empty must fail");
201
1
    assert!(!err2.is_empty());
202
1
}
203

            
204
#[test]
205
1
fn price_referencing_missing_split_is_err() {
206
1
    let splits = format!(
207
        r#"(:id "{S1}" :account-id "{A1}" :commodity-id "{C1}" :value -100)(:id "{S2}" :account-id "{A2}" :commodity-id "{C2}" :value 100)"#
208
    );
209
1
    let prices = format!(
210
        r#"(:id "p1" :commodity-id "{C2}" :currency-id "{C1}" :commodity-split "{S2}" :currency-split "nonexistent-split" :value-num 1 :value-denom 1 :date "{DATE}")"#
211
    );
212
1
    let plist = tx_plist(&splits, &prices, None);
213
1
    let w = wire(&plist);
214

            
215
1
    let err = parse_editable_transaction(&w).expect_err("missing split ref must fail");
216
1
    assert!(
217
1
        err.contains("nonexistent-split"),
218
        "should name missing id: {err}"
219
    );
220
1
}
221

            
222
#[test]
223
1
fn round_trip_integer_value() {
224
1
    let (sign, mag) = super::split_value("-100").expect("must classify");
225
1
    assert_eq!(sign, -1);
226
1
    assert_eq!(mag, "100");
227
1
    let parsed = parse_amount(&mag).expect("must parse");
228
1
    assert_eq!(parsed, Rational64::new(100, 1));
229
1
}
230

            
231
#[test]
232
1
fn round_trip_fractional_value() {
233
1
    let (sign, mag) = super::split_value("-15381/100").expect("must classify");
234
1
    assert_eq!(sign, -1);
235
1
    assert_eq!(mag, "15381/100");
236
1
    let parsed = parse_amount(&mag).expect("must parse");
237
1
    assert_eq!(parsed, Rational64::new(15381, 100));
238
1
}