1
//! Tests for cli-core form descriptors and validators.
2

            
3
#[cfg(test)]
4
mod descriptor_tests {
5
    use super::super::{
6
        descriptors::{ACCOUNT_CREATE, COMMODITY_CONVERT, COMMODITY_CREATE, TRANSACTION_CREATE},
7
        to_sexpr,
8
    };
9

            
10
    #[test]
11
1
    fn account_create_sexpr_contains_form_name() {
12
1
        let s = to_sexpr(&ACCOUNT_CREATE);
13
1
        assert!(
14
1
            s.contains(":form \"account-create\""),
15
            "missing form name: {s}"
16
        );
17
1
    }
18

            
19
    #[test]
20
1
    fn account_create_sexpr_has_required_name_field() {
21
1
        let s = to_sexpr(&ACCOUNT_CREATE);
22
1
        assert!(s.contains(":key \"name\""), "missing name field: {s}");
23
1
        assert!(
24
1
            s.contains(":required t"),
25
            "name field must be required: {s}"
26
        );
27
1
    }
28

            
29
    #[test]
30
1
    fn account_create_sexpr_has_optional_parent_select() {
31
1
        let s = to_sexpr(&ACCOUNT_CREATE);
32
1
        assert!(s.contains(":key \"parent\""), "missing parent field: {s}");
33
1
        assert!(
34
1
            s.contains("(select :source accounts)"),
35
            "parent must be account select: {s}"
36
        );
37
1
        assert!(
38
1
            s.contains(":required nil"),
39
            "parent field must be optional: {s}"
40
        );
41
1
    }
42

            
43
    #[test]
44
1
    fn commodity_create_sexpr_has_required_symbol() {
45
1
        let s = to_sexpr(&COMMODITY_CREATE);
46
1
        assert!(s.contains(":key \"symbol\""), "missing symbol: {s}");
47
1
        assert!(s.contains(":required t"), "symbol must be required: {s}");
48
1
    }
49

            
50
    #[test]
51
1
    fn commodity_create_sexpr_name_is_required() {
52
1
        let s = to_sexpr(&COMMODITY_CREATE);
53
1
        assert!(s.contains(":key \"name\""), "missing name field: {s}");
54
1
        assert!(
55
1
            !s.contains(":required nil"),
56
            "commodity-create has no optional field; name must be required: {s}"
57
        );
58
1
    }
59

            
60
    #[test]
61
1
    fn transaction_create_sexpr_has_splits_widget() {
62
1
        let s = to_sexpr(&TRANSACTION_CREATE);
63
1
        assert!(s.contains(":widget splits"), "missing splits widget: {s}");
64
1
        assert!(s.contains(":key \"date\""), "missing date field: {s}");
65
1
        assert!(s.contains(":key \"note\""), "missing note field: {s}");
66
1
    }
67

            
68
    #[test]
69
1
    fn commodity_convert_sexpr_has_commodity_selects() {
70
1
        let s = to_sexpr(&COMMODITY_CONVERT);
71
1
        assert!(
72
1
            s.contains("(select :source commodities)"),
73
            "missing commodity select: {s}"
74
        );
75
1
        assert!(s.contains(":key \"from\""), "missing from field: {s}");
76
1
        assert!(s.contains(":key \"to\""), "missing to field: {s}");
77
1
    }
78

            
79
    #[test]
80
1
    fn to_sexpr_is_parseable_by_reader() {
81
        use nomiscript::Reader;
82
1
        let s = to_sexpr(&ACCOUNT_CREATE);
83
1
        Reader::parse(&s).expect("to_sexpr must produce valid s-expression");
84
1
    }
85
}
86

            
87
#[cfg(test)]
88
mod validator_tests;
89

            
90
#[cfg(test)]
91
mod account_options_tests {
92
    use super::super::parse_account_options;
93
    use crate::eval::{build_create_account_form, build_create_commodity_form};
94

            
95
    #[test]
96
1
    fn parse_empty_accounts_list_returns_empty() {
97
1
        let wire = r#"(:id 1 :value "()")"#;
98
1
        assert_eq!(parse_account_options(wire), vec![]);
99
1
    }
100

            
101
    #[test]
102
1
    fn parse_garbage_returns_empty() {
103
1
        assert_eq!(parse_account_options("not a wire frame"), vec![]);
104
1
        assert_eq!(parse_account_options(""), vec![]);
105
1
    }
106

            
107
    #[test]
108
1
    fn parse_single_account_extracts_id_and_name() {
109
1
        let uuid = "550e8400-e29b-41d4-a716-446655440001";
110
1
        let wire = format!(
111
            r#"(:id 2 :value "((:account :id \"{uuid}\" :name \"Expenses\" :parent \"\"))")"#
112
        );
113
1
        let result = parse_account_options(&wire);
114
1
        assert_eq!(result.len(), 1);
115
1
        assert_eq!(result[0].0, uuid);
116
1
        assert_eq!(result[0].1, "Expenses");
117
1
    }
118

            
119
    #[test]
120
1
    fn build_create_commodity_form_produces_correct_form() {
121
1
        let f = build_create_commodity_form("USD", "US Dollar");
122
1
        assert_eq!(f, r#"(create-commodity "USD" "US Dollar")"#);
123
1
    }
124

            
125
    #[test]
126
1
    fn build_create_commodity_form_escapes_special_chars() {
127
1
        let f = build_create_commodity_form("US\"D", "Dollar");
128
1
        assert!(f.contains("\\\""), "should escape double quote: {f}");
129
1
    }
130

            
131
    #[test]
132
1
    fn build_create_account_form_no_parent_uses_empty_string() {
133
        // create-account has a fixed arity of 2; a root account passes "" parent.
134
1
        let f = build_create_account_form("Expenses", None);
135
1
        assert_eq!(f, r#"(create-account "Expenses" "")"#);
136
1
    }
137

            
138
    #[test]
139
1
    fn build_create_account_form_empty_parent_uses_empty_string() {
140
1
        let f = build_create_account_form("Expenses", Some(""));
141
1
        assert_eq!(f, r#"(create-account "Expenses" "")"#);
142
1
    }
143

            
144
    #[test]
145
1
    fn build_create_account_form_with_parent() {
146
1
        let uuid = "550e8400-e29b-41d4-a716-446655440001";
147
1
        let f = build_create_account_form("Sub", Some(uuid));
148
1
        assert_eq!(f, format!(r#"(create-account "Sub" "{uuid}")"#));
149
1
    }
150
}
151

            
152
#[cfg(test)]
153
mod update_payload_tests {
154
    use super::super::payload::build_transaction_update_payload;
155
    use super::super::validators::LogicalSplitInput;
156

            
157
    const TX_ID: &str = "tx00-0000-0000-0000-000000000001";
158
    const FROM: &str = "aaaa0000-0000-0000-0000-000000000001";
159
    const TO: &str = "aaaa0000-0000-0000-0000-000000000002";
160
    const COMM: &str = "cccc0000-0000-0000-0000-000000000001";
161
    const COMM2: &str = "cccc0000-0000-0000-0000-000000000002";
162

            
163
3
    fn row_same(value: &str) -> LogicalSplitInput {
164
3
        LogicalSplitInput {
165
3
            from: FROM.to_string(),
166
3
            to: TO.to_string(),
167
3
            from_commodity: COMM.to_string(),
168
3
            to_commodity: COMM.to_string(),
169
3
            amount: value.to_string(),
170
3
            to_amount: None,
171
3
        }
172
3
    }
173

            
174
1
    fn row_cross(value: &str, to_amount: &str) -> LogicalSplitInput {
175
1
        LogicalSplitInput {
176
1
            from: FROM.to_string(),
177
1
            to: TO.to_string(),
178
1
            from_commodity: COMM.to_string(),
179
1
            to_commodity: COMM2.to_string(),
180
1
            amount: value.to_string(),
181
1
            to_amount: Some(to_amount.to_string()),
182
1
        }
183
1
    }
184

            
185
    #[test]
186
1
    fn update_single_currency_produces_update_transaction_logical() {
187
1
        let result = build_transaction_update_payload(TX_ID, &[row_same("50")], "", "").unwrap();
188
1
        assert!(
189
1
            result.starts_with("(update-transaction-logical "),
190
            "form prefix: {result}"
191
        );
192
1
        assert!(result.contains(TX_ID), "tx id in payload: {result}");
193
1
        assert!(
194
1
            result.contains(":transaction-id"),
195
            ":transaction-id key: {result}"
196
        );
197
1
        assert!(result.contains(":value 50"), "integer ratio: {result}");
198
1
        assert!(
199
1
            !result.contains(":to-amount"),
200
            "no to-amount same commodity: {result}"
201
        );
202
1
    }
203

            
204
    #[test]
205
1
    fn update_cross_currency_includes_to_amount() {
206
1
        let result =
207
1
            build_transaction_update_payload(TX_ID, &[row_cross("153.81", "15000")], "", "")
208
1
                .unwrap();
209
1
        assert!(
210
1
            result.contains(":value 15381/100"),
211
            "decimal to ratio: {result}"
212
        );
213
1
        assert!(
214
1
            result.contains(":to-amount 15000"),
215
            "to-amount present: {result}"
216
        );
217
1
    }
218

            
219
    #[test]
220
1
    fn update_note_and_date_included() {
221
1
        let result =
222
1
            build_transaction_update_payload(TX_ID, &[row_same("10")], "Groceries", "2024-01-15")
223
1
                .unwrap();
224
1
        assert!(result.contains(":note"), "note present: {result}");
225
1
        assert!(result.contains(":date"), "date present: {result}");
226
1
        assert!(result.contains("Groceries"), "note text: {result}");
227
1
    }
228

            
229
    #[test]
230
1
    fn update_empty_note_and_date_omitted() {
231
1
        let result = build_transaction_update_payload(TX_ID, &[row_same("5")], "", "").unwrap();
232
1
        assert!(!result.contains(":note"), "no note when empty: {result}");
233
1
        assert!(!result.contains(":date"), "no date when empty: {result}");
234
1
    }
235

            
236
    #[test]
237
1
    fn update_cross_commodity_missing_to_amount_is_error() {
238
1
        let row = LogicalSplitInput {
239
1
            from: FROM.to_string(),
240
1
            to: TO.to_string(),
241
1
            from_commodity: COMM.to_string(),
242
1
            to_commodity: COMM2.to_string(),
243
1
            amount: "10".to_string(),
244
1
            to_amount: None,
245
1
        };
246
1
        assert!(
247
1
            build_transaction_update_payload(TX_ID, &[row], "", "").is_err(),
248
            "cross-commodity without to_amount must fail"
249
        );
250
1
    }
251
}
252

            
253
#[cfg(test)]
254
mod commodity_options_tests {
255
    use super::super::parse_commodity_options;
256

            
257
    #[test]
258
1
    fn parse_empty_commodities_list_returns_empty() {
259
1
        let wire = r#"(:id 1 :value "()")"#;
260
1
        assert_eq!(parse_commodity_options(wire), vec![]);
261
1
    }
262

            
263
    #[test]
264
1
    fn parse_garbage_returns_empty() {
265
1
        assert_eq!(parse_commodity_options("not a wire frame"), vec![]);
266
1
        assert_eq!(parse_commodity_options(""), vec![]);
267
1
    }
268

            
269
    #[test]
270
1
    fn parse_single_commodity_with_name_builds_symbol_dash_name_label() {
271
1
        let uuid = "550e8400-e29b-41d4-a716-446655440002";
272
1
        let wire =
273
1
            format!(r#"(:id 2 :value "((:id \"{uuid}\" :symbol \"USD\" :name \"US Dollar\"))")"#);
274
1
        let result = parse_commodity_options(&wire);
275
1
        assert_eq!(result.len(), 1);
276
1
        assert_eq!(result[0].0, uuid);
277
1
        assert_eq!(result[0].1, "USD \u{2013} US Dollar");
278
1
    }
279

            
280
    #[test]
281
1
    fn parse_commodity_without_name_uses_symbol_only() {
282
1
        let uuid = "550e8400-e29b-41d4-a716-446655440003";
283
1
        let wire = format!(r#"(:id 3 :value "((:id \"{uuid}\" :symbol \"EUR\" :name \"\"))")"#);
284
1
        let result = parse_commodity_options(&wire);
285
1
        assert_eq!(result.len(), 1);
286
1
        assert_eq!(result[0].1, "EUR");
287
1
    }
288

            
289
    #[test]
290
1
    fn parse_multiple_commodities() {
291
1
        let u1 = "550e8400-e29b-41d4-a716-446655440004";
292
1
        let u2 = "550e8400-e29b-41d4-a716-446655440005";
293
1
        let wire = format!(
294
            r#"(:id 4 :value "((:id \"{u1}\" :symbol \"USD\" :name \"Dollar\") (:id \"{u2}\" :symbol \"BTC\" :name \"Bitcoin\"))")"#
295
        );
296
1
        let result = parse_commodity_options(&wire);
297
1
        assert_eq!(result.len(), 2);
298
1
        assert_eq!(result[0].0, u1);
299
1
        assert_eq!(result[1].0, u2);
300
1
        assert_eq!(result[1].1, "BTC \u{2013} Bitcoin");
301
1
    }
302
}